<?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: Dinesh Wijethunga</title>
    <description>The latest articles on DEV Community by Dinesh Wijethunga (@dineshstack).</description>
    <link>https://dev.to/dineshstack</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%2F3550573%2F24b18188-f648-4ede-864e-3b977482ced0.jpg</url>
      <title>DEV Community: Dinesh Wijethunga</title>
      <link>https://dev.to/dineshstack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dineshstack"/>
    <language>en</language>
    <item>
      <title>Connect Jenkins to GitHub: Deploy Keys, PAT and Webhooks</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Sun, 13 Sep 2026 02:25:05 +0000</pubDate>
      <link>https://dev.to/dineshstack/connect-jenkins-to-github-deploy-keys-pat-and-webhooks-198f</link>
      <guid>https://dev.to/dineshstack/connect-jenkins-to-github-deploy-keys-pat-and-webhooks-198f</guid>
      <description>&lt;p&gt;In this tutorial, I will show you step by step how to connect Jenkins to a private GitHub repository with least-privilege credentials — a per-repo deploy key, a read-only fine-grained PAT, and a push webhook — including the two mistakes that each cost a failed build in a real setup.&lt;/p&gt;
&lt;h2&gt;The three credentials and their jobs&lt;/h2&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSH deploy key&lt;/td&gt;
&lt;td&gt;deploy user on the server&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git pull&lt;/code&gt; during deploys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-grained PAT&lt;/td&gt;
&lt;td&gt;Jenkins&lt;/td&gt;
&lt;td&gt;checkout + GitHub API (scan, statuses)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook&lt;/td&gt;
&lt;td&gt;GitHub → Jenkins&lt;/td&gt;
&lt;td&gt;instant builds on push&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;Step 1 — Per-repo deploy key (a convention worth stealing)&lt;/h2&gt;
&lt;p&gt;One key per repo, named accordingly, with an SSH host alias so multiple repo keys coexist on one server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# as the deploy user on the server
ssh-keygen -t ed25519 -f ~/.ssh/id_repo_myapp -N "" -C "deploy@myapp"

cat &amp;gt;&amp;gt; ~/.ssh/config &amp;lt;&amp;lt;'EOF'
Host github-repo-myapp
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_repo_myapp
    IdentitiesOnly yes
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the public key in GitHub → repo → Settings → Deploy keys (read-only), then verify and clone through the alias:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -T git@github-repo-myapp        # "successfully authenticated"
git clone github-repo-myapp:acme/myapp.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2 — The fine-grained PAT (minimal, but watch the expiry)&lt;/h2&gt;
&lt;p&gt;GitHub → Settings → Developer settings → Fine-grained tokens:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repository access:&lt;/strong&gt; Only select repositories → your repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions:&lt;/strong&gt; Contents: &lt;strong&gt;Read-only&lt;/strong&gt; (Metadata is added automatically). That's all a checkout needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiration:&lt;/strong&gt; the default is 30 days — your pipeline will die silently in a month. Choose Custom and set ~1 year, with a reminder before it lapses.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Store it in Jenkins as Username with password: username = your GitHub username, password = the token, ID = &lt;code&gt;github-token&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Gotcha #1 — credential ID is not the username&lt;/h2&gt;
&lt;p&gt;We also store the CI database login in Jenkins. First build failed every single test with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SQLSTATE[HY000] [1045] Access denied for user 'ci-mysql'@'localhost'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ci-mysql&lt;/code&gt; was the &lt;strong&gt;credential's ID&lt;/strong&gt;, not the database username — the dialog had been filled with the ID in the username field. When a Jenkinsfile does &lt;code&gt;credentials('ci-mysql')&lt;/code&gt;, the ID is only the lookup key; the username/password fields are what get injected. If your logs show your credential ID where a username should be, that's the tell.&lt;/p&gt;
&lt;h2&gt;Step 3 — The multibranch job, and gotcha #2&lt;/h2&gt;
&lt;p&gt;New Item → &lt;strong&gt;Multibranch Pipeline&lt;/strong&gt; → Branch Sources → GitHub → credentials &lt;code&gt;github-token&lt;/code&gt;, HTTPS repo URL. Save triggers the first scan — which immediately failed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Could not fetch branches from source
"message":"Resource not accessible by personal access token","status":"403"
… Failed to retrieve …/pulls?state=open
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default behaviors include Discover pull requests from origin/forks, which call the &lt;strong&gt;PR API&lt;/strong&gt; — and a Contents-only token can't. Least privilege collided with default assumptions. Two clean fixes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No PR workflow (our case):&lt;/strong&gt; in Behaviors, delete both Discover pull requests entries and set Discover branches → &lt;strong&gt;All branches&lt;/strong&gt; (the default "exclude branches filed as PRs" strategy also touches the PR API).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR workflow:&lt;/strong&gt; add Pull requests: Read-only to the PAT instead.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rescan: 'Jenkinsfile' found — Scheduled build for branch: master.&lt;/p&gt;
&lt;h2&gt;Step 4 — The webhook&lt;/h2&gt;
&lt;p&gt;GitHub repo → Settings → Webhooks → Add webhook:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Payload URL: &lt;code&gt;https://jenkins.example.com/github-webhook/&lt;/code&gt; (trailing slash matters)&lt;/li&gt;
&lt;li&gt;Content type: &lt;code&gt;application/json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Events: &lt;strong&gt;Just the push event&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GitHub sends a ping on save — look for the green tick under Recent Deliveries. From now on every push builds within seconds; keep the periodic scan off unless you need branch-deletion cleanup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next in the series:&lt;/strong&gt; post #6 is the Jenkinsfile itself — the full stage graph for a Laravel + React app, the CI MySQL user with its dual-host grant quirk, and why &lt;code&gt;withoutVite()&lt;/code&gt; belongs in your base TestCase.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/jenkins-github-deploy-keys-pat-webhooks?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>github</category>
      <category>jenkins</category>
    </item>
    <item>
      <title>How to Run Jenkins Behind Nginx with SSL on a Subdomain</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Thu, 10 Sep 2026 01:00:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/how-to-run-jenkins-behind-nginx-with-ssl-on-a-subdomain-3no0</link>
      <guid>https://dev.to/dineshstack/how-to-run-jenkins-behind-nginx-with-ssl-on-a-subdomain-3no0</guid>
      <description>&lt;p&gt;In this tutorial, I will show you step by step how to run Jenkins behind nginx with SSL on its own subdomain — on a server that already hosts a production app on a &lt;strong&gt;wildcard domain&lt;/strong&gt;, which is exactly where the sharp edges are.&lt;/p&gt;
&lt;p&gt;Setup: one VPS, an existing Laravel SaaS serving &lt;code&gt;*.example.com&lt;/code&gt; (multi-tenant subdomains), and Jenkins on &lt;code&gt;127.0.0.1:8080&lt;/code&gt; that we want at &lt;code&gt;https://jenkins.example.com&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Step 1 — The nginx vhost (exact name beats wildcard)&lt;/h2&gt;
&lt;p&gt;Key nginx fact: when a request arrives, an &lt;strong&gt;exact &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;server_name&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; always wins over a wildcard&lt;/strong&gt;. So even though &lt;code&gt;*.example.com&lt;/code&gt; would swallow &lt;code&gt;jenkins.example.com&lt;/code&gt;, a dedicated vhost takes precedence:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/nginx/sites-available/jenkins
server {
    listen 80;
    server_name jenkins.example.com;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_request_buffering off;   # long-running build log streams
        proxy_read_timeout 90s;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo ln -sf /etc/nginx/sites-available/jenkins /etc/nginx/sites-enabled/jenkins
sudo nginx -t &amp;amp;&amp;amp; sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gotcha from the field: if &lt;code&gt;ln&lt;/code&gt; says File exists, there's a stale symlink from an earlier attempt shadowing your new config — &lt;code&gt;rm&lt;/code&gt; it and re-link. And if the subdomain still shows your main app, the wildcard vhost is winning because your new vhost isn't actually enabled; verify with &lt;code&gt;nginx -T | grep -A2 "server_name jenkins"&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Step 2 — TLS. Read this before running certbot&lt;/h2&gt;
&lt;p&gt;Here is the mistake, so you don't repeat it. I ran the "friendly" installer mode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;certbot --nginx -d jenkins.example.com   # ← DON'T, on a multi-site server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;certbot issued the certificate fine — and then &lt;strong&gt;deployed it into the wrong vhost&lt;/strong&gt;: the production wildcard site. Its &lt;code&gt;ssl_certificate&lt;/code&gt; lines suddenly pointed at the Jenkins cert, and every tenant subdomain on &lt;code&gt;*.example.com&lt;/code&gt; started throwing certificate-mismatch errors. Production TLS, broken by a CI chore.&lt;/p&gt;
&lt;p&gt;The fix was to point those two lines back at the wildcard cert and reload — but the lesson is permanent:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# On any server with more than one vhost: issue only, wire manually
sudo certbot certonly --nginx -d jenkins.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then add the 443 block yourself, where you decide which file changes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 443 ssl;
    server_name jenkins.example.com;

    ssl_certificate     /etc/letsencrypt/live/jenkins.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/jenkins.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass       http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Cloudflare shortcut: maybe you need no cert at all&lt;/h2&gt;
&lt;p&gt;If Cloudflare proxies your zone, check what your origin certificate already covers before issuing anything:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo | openssl s_client -servername app.example.com -connect YOUR_SERVER_IP:443 2&amp;gt;/dev/null \
  | openssl x509 -noout -ext subjectAltName
# X509v3 Subject Alternative Name:
#     DNS:*.example.com, DNS:example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A wildcard SAN like that means any new first-level subdomain can simply &lt;strong&gt;reuse the existing cert files&lt;/strong&gt; in its 443 block — zero certbot, zero new renewals, and Cloudflare's edge certificate covers the browser side. We used exactly this later in the series for the staging vhost.&lt;/p&gt;
&lt;h2&gt;Step 3 — Lock the box down&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# Jenkins should only be reachable through nginx now
sudo ufw deny 8080/tcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also set Manage Jenkins → System → Jenkins URL to &lt;code&gt;https://jenkins.example.com/&lt;/code&gt; so redirects and webhook URLs generate correctly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next in the series:&lt;/strong&gt; post #5 connects Jenkins to GitHub — per-repo deploy keys, a minimal fine-grained PAT, the webhook, and the 403 that breaks multibranch scanning when your token is (correctly) minimal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/jenkins-nginx-ssl-subdomain?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nginx</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Install Jenkins on Ubuntu 24.04 Step by Step</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Sun, 06 Sep 2026 23:00:07 +0000</pubDate>
      <link>https://dev.to/dineshstack/how-to-install-jenkins-on-ubuntu-2404-step-by-step-1kii</link>
      <guid>https://dev.to/dineshstack/how-to-install-jenkins-on-ubuntu-2404-step-by-step-1kii</guid>
      <description>&lt;p&gt;In this tutorial, I will show you step by step how to install Jenkins on Ubuntu 24.04 with a complete, production-tested command sequence — including the two traps that produce "Package 'jenkins' has no installation candidate", both of which I hit on a real server.&lt;/p&gt;
&lt;h2&gt;Step 0 — Add swap if you have none&lt;/h2&gt;
&lt;p&gt;Check first. A box with &lt;code&gt;Swap: 0B&lt;/code&gt; will eventually OOM-kill Jenkins mid-build once your pipeline runs composer, npm and a database-backed test suite at once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;free -h   # if Swap shows 0B:
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile &amp;amp;&amp;amp; sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl -p /etc/sysctl.d/99-swappiness.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;swappiness=10&lt;/code&gt; keeps swap as an emergency parachute, not a performance drag.&lt;/p&gt;
&lt;h2&gt;Step 1 — Java 21&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
sudo apt install -y openjdk-21-jre
java -version   # openjdk 21.x
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2 — The repo, and the two traps&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Trap #1: the keyrings directory doesn't exist.&lt;/strong&gt; Every tutorial pipes the key with &lt;code&gt;wget -O /etc/apt/keyrings/…&lt;/code&gt; — but &lt;code&gt;wget -O&lt;/code&gt; does not create parent directories. On a fresh Ubuntu 24.04, &lt;code&gt;/etc/apt/keyrings&lt;/code&gt; may not exist, wget silently writes nothing usable, and apt later reports no installation candidate. Create it explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /etc/apt/keyrings
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Trap #2: the signing key rotated.&lt;/strong&gt; The widely-copied tutorials reference &lt;code&gt;jenkins.io-2023.key&lt;/code&gt;. That key has been rotated — with it, &lt;code&gt;apt update&lt;/code&gt; fails signature verification and the package is invisible. Use the current key:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo wget -O /etc/apt/keyrings/jenkins-keyring.asc \
    https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key

echo "deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc]" \
    "https://pkg.jenkins.io/debian-stable binary/" | \
    sudo tee /etc/apt/sources.list.d/jenkins.list &amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you're reading this later, check &lt;a href="https://pkg.jenkins.io/debian-stable/" rel="noopener noreferrer"&gt;pkg.jenkins.io/debian-stable&lt;/a&gt; for the current key filename — rotation will happen again.&lt;/p&gt;
&lt;h2&gt;Step 3 — Install and verify&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
apt-cache policy jenkins   # must show a candidate version — if not, revisit step 2
sudo apt install -y jenkins
sudo systemctl enable --now jenkins
systemctl status jenkins   # active (running)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 4 — The setup wizard, opinionated&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo cat /var/lib/jenkins/secrets/initialAdminPassword
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open &lt;code&gt;http://your-server-ip:8080&lt;/code&gt;, paste the password, then:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plugins:&lt;/strong&gt; "Install suggested plugins" is fine. You specifically need: Git, GitHub, Pipeline, Credentials Binding, SSH Agent. Anything missing installs later in two clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create the admin user&lt;/strong&gt; — don't keep running as the unlock account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instance URL:&lt;/strong&gt; set it to the HTTPS subdomain you'll create in post #4 (e.g. &lt;code&gt;https://jenkins.example.com/&lt;/code&gt;), not the bare IP.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One UI note that cost me ten minutes: in current Jenkins (2.5xx), the &lt;strong&gt;# of executors&lt;/strong&gt; setting is no longer on the System page — it moved to Manage Jenkins → Nodes → Built-In Node → Configure. For a VPS that also serves production traffic, set executors to &lt;strong&gt;1&lt;/strong&gt; so two pushes can't run two heavy builds simultaneously.&lt;/p&gt;
&lt;h2&gt;Verification checklist&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;systemctl is-active jenkins          # active
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080  # 403 = up, auth required
free -h                              # swap present
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Next in the series:&lt;/strong&gt; right now Jenkins is exposed on &lt;code&gt;:8080&lt;/code&gt; over plain HTTP. Post #4 puts it behind nginx on its own HTTPS subdomain — and covers the wildcard-vhost mistake that briefly broke TLS on a production domain.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/install-jenkins-ubuntu-24-04?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Laravel 13 Git Hooks with Husky: Pint on Every Commit</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Sat, 05 Sep 2026 19:00:08 +0000</pubDate>
      <link>https://dev.to/dineshstack/laravel-13-git-hooks-with-husky-pint-on-every-commit-240</link>
      <guid>https://dev.to/dineshstack/laravel-13-git-hooks-with-husky-pint-on-every-commit-240</guid>
      <description>&lt;p&gt;In this tutorial, I will show you step by step how to set up git hooks in Laravel 12 with Husky, with a complete example from a production Laravel + React (Inertia) repo.&lt;/p&gt;
&lt;p&gt;The goal: nobody can commit unformatted PHP, and nobody can push broken JavaScript — while keeping every hook fast enough that no one is ever tempted to bypass it.&lt;/p&gt;
&lt;h2&gt;Step 1 — Install Husky&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install --save-dev husky
npx husky init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a &lt;code&gt;.husky/&lt;/code&gt; directory and adds a &lt;code&gt;prepare&lt;/code&gt; script to &lt;code&gt;package.json&lt;/code&gt;, so every developer gets the hooks automatically on &lt;code&gt;npm install&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;"scripts": {
    "prepare": "husky"
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2 — The pre-commit hook: Pint on staged files only&lt;/h2&gt;
&lt;p&gt;The naive version runs Pint on the whole project. Don't — it reformats files your teammate is mid-way through, and it's slow. The correct version formats &lt;strong&gt;only the PHP files in this commit&lt;/strong&gt;, then re-stages them so the fixes ride along:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# .husky/pre-commit
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$' || true)

if [ -n "$STAGED_PHP" ]; then
    echo "husky: pint on staged PHP files…"
    ./vendor/bin/pint $STAGED_PHP
    git add $STAGED_PHP
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--diff-filter=ACM&lt;/code&gt; — added, copied, modified. Deleted files are excluded, otherwise Pint errors on paths that no longer exist.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;|| true&lt;/code&gt; — grep exits non-zero when nothing matches, which would abort every PHP-free commit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git add $STAGED_PHP&lt;/code&gt; — Pint's fixes are re-staged, so what lands in the commit is the formatted version. No follow-up "apply pint" commits, ever.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Step 3 — The pre-push hook: the fast test wall&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# .husky/pre-push
echo "husky: JS tests…"
npm run test:js

echo "husky: pint check…"
./vendor/bin/pint --test

# Optional: uncomment to also run a critical PHP test group (keep it FAST)
# composer test:critical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On our repo the JS suite runs in about 3 seconds and &lt;code&gt;pint --test&lt;/code&gt; (check-only mode, no writes) in about 2. A failed push looks like this and never leaves the machine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;husky: JS tests…
 FAIL  resources/js/__tests__/invoice-totals.test.ts
error: failed to push some refs
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Why the full PHP suite is deliberately NOT in the hooks&lt;/h2&gt;
&lt;p&gt;Our full Laravel suite is 340+ tests against a real MySQL server — several minutes. Here is the uncomfortable truth from every team I've taught this to: &lt;strong&gt;any hook slower than about ten seconds gets bypassed&lt;/strong&gt;. Developers discover &lt;code&gt;git push --no-verify&lt;/code&gt; on day two, and now your "safety net" is theatre.&lt;/p&gt;
&lt;p&gt;So the contract is: hooks catch the cheap mistakes instantly, and the full suite runs in Jenkins on every push — where it blocks the deploy, not the developer. That pipeline is exactly what the rest of this series builds.&lt;/p&gt;
&lt;h2&gt;Verify it works&lt;/h2&gt;
&lt;p&gt;Deliberately mangle a file and try to commit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo 'class   Foo{public function bar( ){return 1 ;}}' &amp;gt;&amp;gt; app/Models/Scratch.php
git add app/Models/Scratch.php &amp;amp;&amp;amp; git commit -m "test hook"
# → pint reformats it, re-stages, commit lands clean
git show --stat HEAD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Next in the series:&lt;/strong&gt; post #3 installs Jenkins on Ubuntu 24.04 — including the two apt traps that produce the infamous "Package 'jenkins' has no installation candidate" error in 2026.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/laravel-13-husky-git-hooks-pint?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>git</category>
      <category>laravel</category>
      <category>php</category>
    </item>
    <item>
      <title>Husky vs Jenkins vs Ansible: When to Use Which for CI/CD?</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Thu, 03 Sep 2026 19:00:08 +0000</pubDate>
      <link>https://dev.to/dineshstack/husky-vs-jenkins-vs-ansible-when-to-use-which-for-cicd-387a</link>
      <guid>https://dev.to/dineshstack/husky-vs-jenkins-vs-ansible-when-to-use-which-for-cicd-387a</guid>
      <description>&lt;p&gt;In this guide, we will cover everything you need to know about choosing between Husky, Jenkins and Ansible for a Laravel CI/CD pipeline — and why the real answer is a layered setup, not a single winner.&lt;/p&gt;
&lt;p&gt;I recently built the full pipeline for a production Laravel 12 + React (Inertia) multi-tenant SaaS running on a single Ubuntu VPS. This series documents that build step by step, including everything that broke along the way. But before touching a terminal, we had to answer the question every team hits: &lt;strong&gt;which tool?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The trap: these are not competing tools&lt;/h2&gt;
&lt;p&gt;The comparison is a category error, and understanding why is the whole decision:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;th&gt;When it fires&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Husky&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Developer's machine&lt;/td&gt;
&lt;td&gt;Git-hook checks (lint, quick tests)&lt;/td&gt;
&lt;td&gt;Before commit / push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Jenkins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your server&lt;/td&gt;
&lt;td&gt;Pipelines: test → build → deploy&lt;/td&gt;
&lt;td&gt;After push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ansible&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your machine → many servers&lt;/td&gt;
&lt;td&gt;Server configuration management&lt;/td&gt;
&lt;td&gt;When you provision servers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Husky cannot deploy. Jenkins cannot stop a bad commit from being created. Ansible does neither — it makes servers identical.&lt;/p&gt;
&lt;h2&gt;The layered architecture we chose&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Developer Mac
 ├─ pre-commit (Husky): Laravel Pint on staged files only  → ~1s
 ├─ pre-push  (Husky): JS test suite + pint --test         → ~5s
 ▼ git push
Jenkins on the VPS
 ├─ Lint → PHP tests (full suite) → Vite build → JS tests
 ├─ Package artifact → deploy to STAGING → smoke test
 ├─ Manual approval gate
 └─ Deploy to PRODUCTION → health check → auto-rollback on failure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two principles drive this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fast checks live locally, slow checks live on the server.&lt;/strong&gt; A pre-commit hook that takes 30 seconds gets bypassed with &lt;code&gt;--no-verify&lt;/code&gt; within a week — I've watched it happen on every team. Pint on staged files takes about a second; the full PHPUnit suite (340+ tests against MySQL) belongs in Jenkins where nobody is waiting with their fingers on the keyboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server is the source of truth.&lt;/strong&gt; Husky is a courtesy that keeps embarrassing commits out of history. Jenkins is the enforcement layer — it runs the suite you can't skip.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Why Jenkins over GitHub Actions?&lt;/h2&gt;
&lt;p&gt;Fair question, and for many teams Actions is the right call. We picked Jenkins because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The VPS had spare capacity (31 GB RAM) — self-hosted CI costs nothing extra, and there are no per-minute charges for a 340-test suite that runs on every push.&lt;/li&gt;
&lt;li&gt;The deploy target &lt;strong&gt;is&lt;/strong&gt; the CI machine. No SSH-from-cloud-runner complexity, no secrets leaving the box.&lt;/li&gt;
&lt;li&gt;Existing Jenkins experience on the team. The best tool is frequently the one you already know how to debug at 1 a.m.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why we skipped Ansible&lt;/h2&gt;
&lt;p&gt;One VPS. Ansible's payoff is "make 10 servers identical" — with a single server, a version-controlled runbook (every command we ran, committed to the repo in &lt;code&gt;docs/&lt;/code&gt;) gives the reproducibility without the abstraction tax. If we grow to multiple app servers, that runbook becomes the Ansible playbook spec.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decision rule you can steal:&lt;/strong&gt; Husky always (it's a 10-minute setup, post #2). Jenkins or Actions for CI — Jenkins if you own idle hardware, Actions if you don't. Ansible only at 3+ servers.&lt;/p&gt;
&lt;h2&gt;What's next in the series&lt;/h2&gt;
&lt;p&gt;Post #2 sets up Husky properly for a Laravel + React repo — including the trick that runs Pint only on staged files and re-stages the fixes automatically, and why the full PHP suite deliberately stays out of the hooks.&lt;/p&gt;
&lt;p&gt;This series documents a real production build. The domains and IPs are anonymised; the mistakes are not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/husky-vs-jenkins-vs-ansible-cicd?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cicd</category>
      <category>devops</category>
      <category>laravel</category>
    </item>
    <item>
      <title>From Zero to Staging: A Real-World Laravel + Node.js Docker Deployment (With Every Bug We Hit)</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:00:08 +0000</pubDate>
      <link>https://dev.to/dineshstack/from-zero-to-staging-a-real-world-laravel-nodejs-docker-deployment-with-every-bug-we-hit-fn3</link>
      <guid>https://dev.to/dineshstack/from-zero-to-staging-a-real-world-laravel-nodejs-docker-deployment-with-every-bug-we-hit-fn3</guid>
      <description>&lt;h1&gt;From Zero to Staging: A Real-World Laravel + Node.js Docker Deployment (With Every Bug We Hit)&lt;/h1&gt;
&lt;p&gt;A step-by-step walkthrough of setting up a clean staging server from scratch — SSH hardening, Docker orchestration, Let's Encrypt SSL, database seeding, and debugging a login that refused to work for six different reasons.&lt;/p&gt;

&lt;h2&gt;The Setup&lt;/h2&gt;
&lt;p&gt;We had a production server (&lt;code&gt;api.dineshstack.ae&lt;/code&gt;) running a Laravel 12 + Node.js microservices stack inside Docker. We needed a staging environment that was an exact mirror of production for mobile testing. The staging server existed but had been used by five different developers over time — it was a graveyard of stale projects, orphaned Docker volumes, and broken nginx configs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; One clean server. One Docker network. Three domains. Zero stale state.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; Before touching anything on a shared server, always survey first. Never assume you know what's there. A five-minute &lt;code&gt;df -h&lt;/code&gt; and &lt;code&gt;docker ps -a&lt;/code&gt; saves hours of debugging phantom behaviour caused by leftover containers or conflicting ports.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 1 — SSH Hardening&lt;/h2&gt;
&lt;p&gt;The first step before anything else: lock down the door.&lt;/p&gt;
&lt;h3&gt;On your local machine&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Generate a dedicated key for this server&lt;br&gt;
ssh-keygen -t ed25519 -C "taggo_staging" -f ~/.ssh/dstack_staging

&lt;h1&gt;
  
  
  Copy the public key to the server
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;ssh-copy-id -i ~/.ssh/dstack_staging.pub taggo@&amp;lt;SERVER_IP&amp;gt;&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;Then add a clean alias to &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host dstack_staging&lt;br&gt;&lt;br&gt;
    HostName &amp;lt;SERVER_IP&amp;gt;&lt;br&gt;&lt;br&gt;
    User taggo&lt;br&gt;&lt;br&gt;
    IdentityFile ~/.ssh/dstack_staging&lt;br&gt;&lt;br&gt;
    IdentitiesOnly yes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you connect with just &lt;code&gt;ssh dstack_staging&lt;/code&gt;. No password, no ambiguity about which key to use.&lt;/p&gt;
&lt;h3&gt;On the server — disable password auth&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo sed -i 's/^#\?PasswordAuthentication.&lt;em&gt;/PasswordAuthentication no/' /etc/ssh/sshd_config&lt;br&gt;&lt;br&gt;
sudo sed -i 's/^#\?PubkeyAuthentication.&lt;/em&gt;/PubkeyAuthentication yes/' /etc/ssh/sshd_config&lt;br&gt;&lt;br&gt;
sudo systemctl reload ssh&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Install fail2ban&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install fail2ban -y&lt;br&gt;&lt;br&gt;
sudo systemctl enable fail2ban --now&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; is the flag most tutorials skip. Without it, SSH will try every key in your agent, which can confuse servers with strict attempt limits. Always be explicit.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 2 — Server Survey&lt;/h2&gt;
&lt;p&gt;Before deleting anything, map exactly what exists.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Who's taking up disk?&lt;br&gt;&lt;br&gt;
du -sh /var/www/* | sort -rh

&lt;h1&gt;
  
  
  What containers are running?
&lt;/h1&gt;

&lt;p&gt;docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"&lt;/p&gt;

&lt;h1&gt;
  
  
  What networks exist?
&lt;/h1&gt;

&lt;p&gt;docker network ls&lt;/p&gt;

&lt;h1&gt;
  
  
  What volumes exist?
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;docker volume ls&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;What we found:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;49 GB&lt;/strong&gt; across 8 stale project folders in &lt;code&gt;/var/www/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Three different Docker Compose setups from three different developers, all creating their own networks&lt;/li&gt;
&lt;li&gt;11 nginx site configs, only 1 needed&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;erpdstack-mysql&lt;/code&gt; container with live data that must not be touched&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; &lt;code&gt;docker ps -aq --filter name=dstack&lt;/code&gt; matches substrings. &lt;code&gt;erpdstack&lt;/code&gt; contains &lt;code&gt;dstack&lt;/code&gt;. Always preview your filter before passing it to &lt;code&gt;docker rm&lt;/code&gt;. We learned this the hard way — the erpdstack container was removed. The data volume survived, but the container had to be recreated. Always verify, never assume.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 3 — Full Server Reset&lt;/h2&gt;
&lt;p&gt;We removed everything dstack-related except the erpdstack data volume and network.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Stop and remove all containers (carefully — verified list first)&lt;br&gt;&lt;br&gt;
docker stop $(docker ps -aq) &amp;amp;&amp;amp; docker rm $(docker ps -aq)

&lt;h1&gt;
  
  
  Remove stale named volumes
&lt;/h1&gt;

&lt;p&gt;docker volume rm dstack_sail-mysql dstack_sail-redis dstack_sail-kafka \&lt;br&gt;
  dstack_sail-grafana dstack_sail-prometheus&lt;/p&gt;

&lt;h1&gt;
  
  
  Remove stale networks
&lt;/h1&gt;

&lt;p&gt;docker network rm dstack_sail&lt;/p&gt;

&lt;h1&gt;
  
  
  Remove all project folders
&lt;/h1&gt;

&lt;p&gt;sudo rm -rf /var/www/dstack /var/www/dstack_api /var/www/dstack-services \&lt;br&gt;
  /var/www/dinesh-dstack-testing /var/www/nextjs \&lt;br&gt;
  /var/www/dsstack-admin-dash /var/www/dstack-admin-dashboard \&lt;br&gt;
  /var/www/dstack-dashboard&lt;/p&gt;

&lt;h1&gt;
  
  
  Purge unused Docker images
&lt;/h1&gt;

&lt;p&gt;docker image prune -af&lt;/p&gt;

&lt;h1&gt;
  
  
  Clean apt cache and journal logs
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;sudo apt-get clean&lt;br&gt;&lt;br&gt;
sudo journalctl --vacuum-size=100M&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Disk dropped from 70 GB used (73%) to 21 GB (22%). 49 GB recovered in under ten minutes.&lt;/p&gt;

&lt;h2&gt;Phase 4 — Fresh Deployment&lt;/h2&gt;
&lt;h3&gt;Clone the projects&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /home/dstack

&lt;h1&gt;
  
  
  dstack (Laravel) — uses GitHub deploy key 1
&lt;/h1&gt;

&lt;p&gt;git clone git@github-main:orions-it/dstack_api.git dstack&lt;br&gt;
cd dstack &amp;amp;&amp;amp; git checkout docker-standalone&lt;/p&gt;

&lt;h1&gt;
  
  
  dstack-services (Node.js) — uses GitHub deploy key 2
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;cd /home/dstack&lt;br&gt;&lt;br&gt;
git clone git@github-second:DishKief/taggo-services.git dstack-services&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;h3&gt;Configure staging .env files&lt;/h3&gt;
&lt;p&gt;Key differences from production:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# dstack/.env — staging overrides&lt;br&gt;&lt;br&gt;
APP_ENV=staging&lt;br&gt;&lt;br&gt;
APP_DEBUG=true&lt;br&gt;&lt;br&gt;
APP_URL=&lt;a href="https://api-staging.dstack.ae" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://api-staging.dstack.ae" rel="noopener noreferrer"&gt;https://api-staging.dstack.ae&lt;/a&gt;&lt;br&gt;&lt;br&gt;
DB_DATABASE=dstack_staging

&lt;h1&gt;
  
  
  dstack-services/.env
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;NODE_ENV=staging&lt;br&gt;&lt;br&gt;
LARAVEL_UPSTREAM_URL=&lt;a href="http://laravel_dstack:81" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="http://laravel_dstack:81" rel="noopener noreferrer"&gt;http://laravel_dstack:81&lt;/a&gt;&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;h3&gt;Start the stack — order matters&lt;/h3&gt;
&lt;p&gt;Kafka and MySQL are dependencies. Start the infrastructure side first.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Start dstack-services first (Kafka, Redis, MySQL, monitoring)&lt;br&gt;&lt;br&gt;
cd /home/dstack/dstack-services&lt;br&gt;&lt;br&gt;
docker compose up -d --build

&lt;h1&gt;
  
  
  Wait ~60 seconds for MySQL and Kafka to become healthy, then start Laravel
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;cd /home/dstack/dstack&lt;br&gt;&lt;br&gt;
docker compose --profile kafka up -d --build&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Bug we hit:&lt;/strong&gt; The queue, scheduler, and Reverb containers all have &lt;code&gt;depends_on: service_healthy&lt;/code&gt; on MySQL. If you run &lt;code&gt;docker compose up&lt;/code&gt; before MySQL is healthy, those containers start and immediately exit. The fix: wait for MySQL to show &lt;code&gt;(healthy)&lt;/code&gt; in &lt;code&gt;docker ps&lt;/code&gt;, then run &lt;code&gt;docker compose up -d&lt;/code&gt; again. Docker Compose is idempotent — it only starts what isn't running.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h3&gt;External volumes must be pre-created&lt;/h3&gt;
&lt;p&gt;The dstack-services compose declares Prometheus and Grafana volumes as &lt;code&gt;external: true&lt;/code&gt;. Docker will refuse to start if they don't exist.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker volume create --name dstack_sail-prometheus&lt;br&gt;&lt;br&gt;
docker volume create --name dstack_sail-grafana&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; &lt;code&gt;external: true&lt;/code&gt; in a Compose file means "this volume was created outside this file — don't manage its lifecycle." It's a design choice that prevents accidental deletion during &lt;code&gt;docker compose down -v&lt;/code&gt;. The trade-off: you must remember to create it manually when deploying fresh.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h3&gt;Run migrations and bootstrap Laravel&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /home/dstack/dstack

&lt;h1&gt;
  
  
  Create the staging database
&lt;/h1&gt;

&lt;p&gt;docker compose exec mysql_dstack mysql -uroot -p -e \&lt;br&gt;
  "CREATE DATABASE IF NOT EXISTS dstack_staging CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"&lt;/p&gt;

&lt;h1&gt;
  
  
  Install PHP dependencies (without --no-dev — Telescope needs dev packages)
&lt;/h1&gt;

&lt;p&gt;docker compose exec laravel_dstack composer install&lt;/p&gt;

&lt;h1&gt;
  
  
  Run all migrations
&lt;/h1&gt;

&lt;p&gt;docker compose exec laravel_dstack php artisan migrate --force&lt;/p&gt;

&lt;h1&gt;
  
  
  Bootstrap
&lt;/h1&gt;

&lt;p&gt;docker compose exec laravel_dstack php artisan config:cache&lt;br&gt;
docker compose exec laravel_dstack php artisan storage:link&lt;/p&gt;

&lt;h1&gt;
  
  
  Generate Passport keys and create password grant client
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;docker compose exec laravel_dstack php artisan passport:keys&lt;br&gt;&lt;br&gt;
docker compose exec laravel_dstack php artisan passport:client --password --name="DStack Password Grant" --no-interaction&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Bug we hit:&lt;/strong&gt; Running &lt;code&gt;composer install --no-dev&lt;/code&gt; failed because Laravel Telescope is in &lt;code&gt;require-dev&lt;/code&gt; and the &lt;code&gt;TelescopeServiceProvider&lt;/code&gt; is registered in &lt;code&gt;bootstrap/providers.php&lt;/code&gt;. If the package isn't installed, every artisan command throws a fatal error. Lesson: on staging, always run a full &lt;code&gt;composer install&lt;/code&gt;. Only strip &lt;code&gt;--no-dev&lt;/code&gt; on production where Telescope genuinely isn't needed.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 5 — SSL with Let's Encrypt&lt;/h2&gt;
&lt;p&gt;This is the step most tutorials get wrong. You cannot have an nginx config referencing SSL certificates that don't exist yet — nginx will refuse to start.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The correct order:&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Step 1 — HTTP-only config first&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;server {&lt;br&gt;&lt;br&gt;
    listen 80;&lt;br&gt;&lt;br&gt;
    listen [::]:80;&lt;br&gt;&lt;br&gt;
    server_name api-staging.dstack.ae;&lt;br&gt;&lt;br&gt;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }&lt;br&gt;&lt;br&gt;
    location / { return 301 https://$host$request_uri; }&lt;br&gt;&lt;br&gt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo ln -s /etc/nginx/sites-available/api-staging.dstack.ae /etc/nginx/sites-enabled/&lt;br&gt;&lt;br&gt;
sudo nginx -t &amp;amp;&amp;amp; sudo systemctl reload nginx&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2 — Issue the certificate&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo certbot certonly --nginx -d api-staging.dstack.ae&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3 — Replace with full HTTPS config&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;server {&lt;br&gt;&lt;br&gt;
    listen 443 ssl http2;&lt;br&gt;&lt;br&gt;
    listen [::]:443 ssl;&lt;br&gt;&lt;br&gt;
    server_name api-staging.dstack.ae;

&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssl_certificate     /etc/letsencrypt/live/api-staging.dstack.ae/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api-staging.dstack.ae/privkey.pem;
include             /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

add_header X-Robots-Tag "noindex, nofollow" always;
client_max_body_size 100M;

location /realtime/ {
    proxy_pass         http://127.0.0.1:3010/;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade    $http_upgrade;
    proxy_set_header   Connection "upgrade";
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_read_timeout 86400s;
}

location /app  { proxy_pass http://127.0.0.1:6001; ... }
location /apps { proxy_pass http://127.0.0.1:6001; ... }

location / {
    proxy_pass http://127.0.0.1:3006;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;}&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; Notice that &lt;code&gt;/&lt;/code&gt; proxies to port &lt;code&gt;3006&lt;/code&gt;, not directly to Laravel. The Node.js gateway sits in front of Laravel and handles auth token validation, correlation ID injection, and request routing. Laravel is never directly exposed to the internet. This is the production architecture mirrored exactly.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 6 — Staging Identity Headers&lt;/h2&gt;
&lt;p&gt;We added two more subdomains — &lt;code&gt;grafana-staging.dstack.ae&lt;/code&gt; (monitoring) and &lt;code&gt;realtime-staging.dstack.ae&lt;/code&gt; (WebSocket service). To make it immediately obvious these are staging, every nginx config adds identification headers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header X-Robots-Tag  "noindex, nofollow"    always;&lt;br&gt;&lt;br&gt;
add_header X-Environment "staging"              always;&lt;br&gt;&lt;br&gt;
add_header X-Backend     "api-staging.taggo.ae" always;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can verify this on any response:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -sI &lt;a href="https://grafana-staging.dstack.ae/login" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://grafana-staging.dstack.ae/login" rel="noopener noreferrer"&gt;https://grafana-staging.dstack.ae/login&lt;/a&gt; | grep -E "x-environment|x-backend"

&lt;h1&gt;
  
  
  x-environment: staging
&lt;/h1&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  x-backend: api-staging.dstack.ae&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; &lt;code&gt;X-Robots-Tag: noindex, nofollow&lt;/code&gt; prevents search engines from indexing staging. Without it, a staging endpoint once appeared in Google search results — exposing internal API structure. Defence in depth: even if Cloudflare DNS accidentally proxies staging, these headers are a last line.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 7 — Database Seeding&lt;/h2&gt;
&lt;p&gt;With the stack running, seed the database so there's something to log in with.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /home/dstack/dstack

&lt;h1&gt;
  
  
  Core: permissions, roles, admin + dummy users
&lt;/h1&gt;

&lt;p&gt;docker compose exec laravel_dstack php artisan db:seed&lt;/p&gt;

&lt;h1&gt;
  
  
  Module seeders: booking config, locations, currency
&lt;/h1&gt;

&lt;p&gt;docker compose exec laravel_dstack php artisan db:seed \&lt;br&gt;
  --class="Modules\Locations\Database\Seeders\LocationsDatabaseSeeder"&lt;/p&gt;

&lt;p&gt;docker compose exec laravel_dstack php artisan db:seed \&lt;br&gt;
  --class="Modules\Currency\Database\Seeders\CurrencyDatabaseSeeder"&lt;/p&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;docker compose exec laravel_dstack php artisan db:seed &amp;lt;br&amp;gt;&lt;br&gt;
  --class="Modules\BookingConfig\Database\Seeders\BookingConfigDatabaseSeeder"&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;h3&gt;Bug: Role assignment silently failed&lt;/h3&gt;
&lt;p&gt;After seeding, the admin user had no role. This is because Spatie Permission in this project is team-scoped — every role assignment requires a &lt;code&gt;team_id&lt;/code&gt;. The seeder was running &lt;code&gt;$user-&amp;gt;assignRole('sys_admin')&lt;/code&gt; without setting the team context first.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Wrong — team_id would be null, MySQL throws a NOT NULL constraint&lt;br&gt;&lt;br&gt;
$user-&amp;gt;assignRole('sys_admin');

&lt;/code&gt;&lt;p&gt;&lt;code&gt;// Correct — set team context first&lt;br&gt;&lt;br&gt;
app(PermissionRegistrar::class)-&amp;gt;setPermissionsTeamId($user-&amp;gt;current_team_id);&lt;br&gt;&lt;br&gt;
$user-&amp;gt;assignRole('sys_admin');&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;You can verify the actual state by checking &lt;code&gt;model_has_roles&lt;/code&gt; directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec laravel_dstack php artisan tinker --execute "&lt;br&gt;&lt;br&gt;
DB::table('model_has_roles')&lt;br&gt;&lt;br&gt;
    -&amp;gt;join('roles', 'roles.id', '=', 'model_has_roles.role_id')&lt;br&gt;&lt;br&gt;
    -&amp;gt;select('model_has_roles.model_id', 'roles.name as role', 'model_has_roles.team_id')&lt;br&gt;&lt;br&gt;
    -&amp;gt;get()-&amp;gt;each(fn(\$r) =&amp;gt; print(\$r-&amp;gt;model_id.' | '.\$r-&amp;gt;role.' | team='.\$r-&amp;gt;team_id.PHP_EOL));&lt;br&gt;&lt;br&gt;
"&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; When a relationship returns empty in Eloquent but you're sure the data should be there, always go one level deeper and query the pivot table directly. Eloquent's &lt;code&gt;$user-&amp;gt;roles&lt;/code&gt; will silently return an empty collection if the team scope isn't set — no error, no warning. Direct SQL never lies.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Phase 8 — The Login That Refused to Work&lt;/h2&gt;
&lt;p&gt;This phase deserves its own section. Getting the admin login working required diagnosing six separate issues in sequence. Each one was a lesson.&lt;/p&gt;
&lt;h3&gt;Bug 1: Wrong endpoint&lt;/h3&gt;
&lt;p&gt;We called &lt;code&gt;POST /api/v1/public/auth/login&lt;/code&gt; with the sys_admin credentials and got &lt;code&gt;messages.invalid_user&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Reading the frontend login controller revealed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$allowedRoles = [&lt;br&gt;&lt;br&gt;
    RoleEnum::agent_admin-&amp;gt;name,&lt;br&gt;&lt;br&gt;
    RoleEnum::customer-&amp;gt;name,&lt;br&gt;&lt;br&gt;
    RoleEnum::dealer-&amp;gt;name,&lt;br&gt;&lt;br&gt;
    RoleEnum::driver-&amp;gt;name,&lt;br&gt;&lt;br&gt;
    // sys_admin is intentionally NOT here&lt;br&gt;&lt;br&gt;
];

&lt;/code&gt;&lt;p&gt;&lt;code&gt;if (! $user-&amp;gt;hasAnyRole($allowedRoles)) {&lt;br&gt;&lt;br&gt;
    return self::error(__('messages.invalid_user'), 401);&lt;br&gt;&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;The admin has a dedicated endpoint: &lt;code&gt;POST /api/v1/admin/auth/login&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Before debugging credentials, always verify you're hitting the right endpoint. The route file is the source of truth — &lt;code&gt;grep -n "login" routes/apiAdmin.php&lt;/code&gt; takes five seconds.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h3&gt;Bug 2: artisan serve was in FATAL state&lt;/h3&gt;
&lt;p&gt;The admin endpoint returned &lt;code&gt;upstream_unavailable&lt;/code&gt; from the gateway. Testing directly inside the container confirmed port 81 was not listening. The Laravel container showed as running but no PHP process was active.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker logs dstack-laravel_dstack-1 --tail 20

&lt;h1&gt;
  
  
  WARN exited: php (exit status 255; not expected)
&lt;/h1&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  WARN gave up: php entered FATAL state, too many start retries too quickly&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;p&gt;Supervisor had given up on the PHP process because it crashed on startup before &lt;code&gt;composer install&lt;/code&gt; ran (vendor/autoload.php didn't exist at container first boot). After we ran &lt;code&gt;composer install&lt;/code&gt;, the vendor directory appeared — but supervisor had already stopped retrying.&lt;/p&gt;
&lt;p&gt;Fix:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose restart laravel_dstack

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  Supervisor retries the PHP process fresh — this time vendor exists → success&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; A container showing as "running" only means PID 1 (supervisor) is alive. The actual application process inside might be dead. Always verify with &lt;code&gt;docker logs&lt;/code&gt; before assuming the app is up.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h3&gt;Bug 3: artisan serve visible, but login still returns 500&lt;/h3&gt;
&lt;p&gt;After restarting, the server was up but login returned &lt;code&gt;"An error occurred. Please try again later."&lt;/code&gt;. The global exception handler was swallowing the real error:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// bootstrap/app.php

&lt;/code&gt;&lt;p&gt;&lt;code&gt;if (! app()-&amp;gt;environment(['local', 'testing']) &amp;amp;&amp;amp; $response-&amp;gt;getStatusCode() === 500) {&lt;br&gt;&lt;br&gt;
    return response()-&amp;gt;json(['status' =&amp;gt; false, 'message' =&amp;gt; 'An error occurred. Please try again later.'], 500);&lt;br&gt;&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;On staging, every 500 becomes this generic message. We couldn't see the real error. Even &lt;code&gt;Log::error()&lt;/code&gt; in the controller wasn't writing because:&lt;/p&gt;
&lt;h3&gt;Bug 4: Log file was root-owned, sail user couldn't write&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ls -la storage/logs/

&lt;h1&gt;
  
  
  -rw-r--r-- 1 root root 6310 Jul 18 10:57 laravel-2026-07-18.log
&lt;/h1&gt;

&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  ^^^^ sail cannot write here&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;p&gt;The log file was created by an artisan command that ran as root (inside the container). The artisan serve process runs as the &lt;code&gt;sail&lt;/code&gt; user. &lt;code&gt;644&lt;/code&gt; means owner-write only. The sail user silently failed to write any logs.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec dstack-laravel_dstack-1 chown sail:sail /var/www/html/storage/logs/laravel-2026-07-18.log&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the real error appeared in the log:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[2026-07-18 16:44:06] staging.ERROR: Admin login token issue

&lt;/code&gt;&lt;p&gt;&lt;code&gt;{"error":"Key path \"file:///var/www/html/storage/passport/oauth-private.key\" does not exist or is not readable"}&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; When debugging a silent 500, the very first thing to check is whether the log file is writable by the process that's serving requests. A log that can't write is worse than no log — it creates the illusion that nothing is wrong.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h3&gt;Bug 5: Passport private key was root-owned&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;passport:keys&lt;/code&gt; ran as root and created the keys with &lt;code&gt;-rw-------&lt;/code&gt; root ownership. The sail user who runs artisan serve couldn't read the private key to sign JWTs.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec dstack-laravel_dstack-1 ls -la /var/www/html/storage/passport/

&lt;h1&gt;
  
  
  -rw------- 1 root root 3322 oauth-private.key   ← sail cannot read
&lt;/h1&gt;

&lt;h1&gt;
  
  
  -rw-rw---- 1 root root  812 oauth-public.key
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Fix ownership and permissions
&lt;/h1&gt;

&lt;p&gt;docker exec dstack-laravel_dstack-1 chown sail:sail \&lt;br&gt;
  /var/www/html/storage/passport/oauth-private.key \&lt;br&gt;
  /var/www/html/storage/passport/oauth-public.key&lt;/p&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;docker exec dstack-laravel_dstack-1 chmod 600 /var/www/html/storage/passport/oauth-private.key&lt;br&gt;&lt;br&gt;
docker exec dstack-laravel_dstack-1 chmod 644 /var/www/html/storage/passport/oauth-public.key&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; Passport (and any RSA/EC key-based system) requires strict permissions on the private key. Passport v13 specifically checks and will reject &lt;code&gt;644&lt;/code&gt; on the private key — it wants &lt;code&gt;600&lt;/code&gt; or &lt;code&gt;660&lt;/code&gt;. The public key can be world-readable (&lt;code&gt;644&lt;/code&gt;) since it's meant to be distributed.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h3&gt;Bug 6: Missing Passport personal access client&lt;/h3&gt;
&lt;p&gt;After fixing the key permissions, login failed again with a different error. Running the token issuance code manually in tinker revealed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RuntimeException: Personal access client not found for 'users' user provider. Please create one.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;passport:client --password&lt;/code&gt; we ran earlier created a password grant client. But &lt;code&gt;$user-&amp;gt;createToken()&lt;/code&gt; (which the app uses internally) requires a separate &lt;strong&gt;personal access client&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec laravel_taggo php artisan passport:client &amp;lt;br&amp;gt;&lt;br&gt;
  --personal &amp;lt;br&amp;gt;&lt;br&gt;
  --name="DStack Personal Access Client" &amp;lt;br&amp;gt;&lt;br&gt;
  --no-interaction&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the client was created with the correct grant type:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec laravel_dstack php artisan tinker --execute "&lt;br&gt;&lt;br&gt;
DB::table('oauth_clients')-&amp;gt;get()-&amp;gt;each(fn(\$c) =&amp;gt;&lt;br&gt;&lt;br&gt;
    print(\$c-&amp;gt;id.' | '.\$c-&amp;gt;name.' | '.\$c-&amp;gt;grant_types.PHP_EOL)&lt;br&gt;&lt;br&gt;
);&lt;br&gt;&lt;br&gt;
"
&lt;br&gt;
&lt;/code&gt;&lt;h1&gt;&lt;code&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  019f7515... | DStack Personal Access Client | ["personal_access"]&lt;/code&gt;&lt;/h1&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Teaching note:&lt;/strong&gt; In Passport v13, there are three distinct client types: &lt;code&gt;password&lt;/code&gt; grant (for mobile apps using username+password), &lt;code&gt;personal_access&lt;/code&gt; (for server-side token creation via &lt;code&gt;$user-&amp;gt;createToken()&lt;/code&gt;), and &lt;code&gt;authorization_code&lt;/code&gt; (for OAuth flows). Most apps need all three. Running only &lt;code&gt;passport:client --password&lt;/code&gt; is a common mistake that only surfaces when you try to programmatically issue tokens.&lt;/p&gt;&lt;/blockquote&gt;

&lt;h2&gt;Final Verification&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;curl -s -X POST &lt;a href="https://api-staging.dstack.ae/api/v1/admin/auth/login" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://api-staging.dstack.ae/api/v1/admin/auth/login" rel="noopener noreferrer"&gt;https://api-staging.dstack.ae/api/v1/admin/auth/login&lt;/a&gt; \

&lt;/code&gt;&lt;p&gt;&lt;code&gt;-H "Content-Type: application/json" &amp;lt;br&amp;gt;&lt;br&gt;
  -H "Accept: application/json" &amp;lt;br&amp;gt;&lt;br&gt;
  -d '{"email":"&lt;a href="mailto:admin@dineshstack.com"&gt;&lt;/a&gt;&lt;a href="mailto:admin@dineshstack.com"&gt;admin@dineshstack.com&lt;/a&gt;","password":"Admin@dstack2027"}'&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{&lt;br&gt;&lt;br&gt;
    "status": true,&lt;br&gt;&lt;br&gt;
    "data": {&lt;br&gt;&lt;br&gt;
        "account_status": "active",&lt;br&gt;&lt;br&gt;
        "token": {&lt;br&gt;&lt;br&gt;
            "access_token": "eyJ0eXAiOiJKV1Qi...",&lt;br&gt;&lt;br&gt;
            "token_type": "Bearer",&lt;br&gt;&lt;br&gt;
            "access_token_expires_in": 86400&lt;br&gt;&lt;br&gt;
        },&lt;br&gt;&lt;br&gt;
        "user": {&lt;br&gt;&lt;br&gt;
            "email": "&lt;a href="mailto:admin@dineshstack.com"&gt;&lt;/a&gt;&lt;a href="mailto:admin@dineshstack.com"&gt;admin@dineshstack.com&lt;/a&gt;",&lt;br&gt;&lt;br&gt;
            "role": "sys_admin",&lt;br&gt;&lt;br&gt;
            "permissions": [...]&lt;br&gt;&lt;br&gt;
        }&lt;br&gt;&lt;br&gt;
    }&lt;br&gt;&lt;br&gt;
}&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;What the Final State Looks Like&lt;/h2&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://api-staging.dstack.ae" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://api-staging.dstack.ae" rel="noopener noreferrer"&gt;https://api-staging.dstack.ae&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Laravel API (via Node gateway)&lt;/td&gt;
&lt;td&gt;3006 → Laravel:81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://grafana-staging.dstack.ae" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://grafana-staging.dstack.ae" rel="noopener noreferrer"&gt;https://grafana-staging.dstack.ae&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Grafana monitoring dashboard&lt;/td&gt;
&lt;td&gt;3003&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://realtime-staging.dstack.ae" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://realtime-staging.dstack.ae" rel="noopener noreferrer"&gt;https://realtime-staging.dstack.ae&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;WebSocket / node-realtime&lt;/td&gt;
&lt;td&gt;3010&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Docker:&lt;/strong&gt; 19 containers, all on a single &lt;code&gt;dstack_sail&lt;/code&gt; network.&lt;br&gt;&lt;strong&gt;Disk:&lt;/strong&gt; 31 GB / 96 GB (32%) — recovered 39 GB from the old state.&lt;br&gt;&lt;strong&gt;SSL:&lt;/strong&gt; Let's Encrypt on all three domains, auto-renewing.&lt;/p&gt;

&lt;h2&gt;Lessons Condensed&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Survey before you touch anything.&lt;/strong&gt; &lt;code&gt;df -h&lt;/code&gt;, &lt;code&gt;docker ps&lt;/code&gt;, &lt;code&gt;docker network ls&lt;/code&gt; — know the battlefield.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker filter substrings bite.&lt;/strong&gt; &lt;code&gt;--filter name=dstack&lt;/code&gt; matches &lt;code&gt;erpdstack&lt;/code&gt;. Always &lt;code&gt;echo&lt;/code&gt; your list before piping to &lt;code&gt;rm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order matters for SSL.&lt;/strong&gt; HTTP-only config → certbot → HTTPS config. Never the other way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;depends_on: healthy is not a guarantee.&lt;/strong&gt; If dependencies weren't healthy when &lt;code&gt;up&lt;/code&gt; first ran, run it again. Idempotent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Running" container ≠ working app.&lt;/strong&gt; Supervisor alive ≠ PHP alive. Check &lt;code&gt;docker logs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log file permissions are invisible failures.&lt;/strong&gt; A root-owned log file silently eats every error. Always verify &lt;code&gt;ls -la storage/logs/&lt;/code&gt; against the user running your web process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passport needs two clients.&lt;/strong&gt; &lt;code&gt;--password&lt;/code&gt; for mobile auth flows. &lt;code&gt;--personal&lt;/code&gt; for server-side &lt;code&gt;$user-&amp;gt;createToken()&lt;/code&gt;. Both. Always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSA private keys must be readable by the web process user — and nothing else.&lt;/strong&gt; &lt;code&gt;600&lt;/code&gt; on the private key, owned by the process user. Non-negotiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team-scoped permissions require team context on every operation.&lt;/strong&gt; &lt;code&gt;setPermissionsTeamId()&lt;/code&gt; before any role assign or check. Spatie fails silently without it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When stuck on a generic 500, reproduce in tinker step by step.&lt;/strong&gt; Narrow the failure to a single line before reading any framework source.&lt;/li&gt;
&lt;/ol&gt;


</description>
      <category>deployment</category>
      <category>devops</category>
      <category>docker</category>
      <category>laravel</category>
    </item>
    <item>
      <title>How to Set Up SSH Key Authentication on Mac (and Why You Should Never Use Passwords)</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:40:07 +0000</pubDate>
      <link>https://dev.to/dineshstack/how-to-set-up-ssh-key-authentication-on-mac-and-why-you-should-never-use-passwords-9pp</link>
      <guid>https://dev.to/dineshstack/how-to-set-up-ssh-key-authentication-on-mac-and-why-you-should-never-use-passwords-9pp</guid>
      <description>&lt;h2&gt;How to Set Up SSH Key Authentication on Mac (and Why You Should Never Use Passwords)&lt;/h2&gt;
&lt;p&gt;If you've been logging into servers with a username and password, you're one leaked credential away from losing that server. SSH key authentication isn't just "more secure" — it changes the entire threat model. Once you understand how it works, you'll never go back.&lt;/p&gt;
&lt;p&gt;This guide walks you through the full setup on a Mac, step by step, with the reasoning behind every decision.&lt;/p&gt;
&lt;h3&gt;First, understand what you're actually building&lt;/h3&gt;
&lt;p&gt;SSH key authentication works like a padlock you put on a door. You keep the key (private key) on your laptop. You put the matching padlock (public key) on the server. When you connect, SSH proves you hold the key — without ever sending it over the network. Even if someone intercepts every byte of traffic, they learn nothing useful.&lt;/p&gt;
&lt;p&gt;A password, by contrast, travels to the server on every login. One phishing attempt, one leaked database, one shoulder-surf — and it's gone.&lt;/p&gt;
&lt;h3&gt;Step 1 — Generate a dedicated key pair for this server&lt;/h3&gt;
&lt;p&gt;Most people generate one SSH key and reuse it everywhere. Don't. Use a separate key per server or per purpose. That way, if one key is ever compromised, you revoke it in one place without touching anything else.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh-keygen -t ed25519 -C "you@server-name" -f ~/.ssh/server_name&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Breaking this down:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t ed25519&lt;/code&gt; — the algorithm. Ed25519 is the modern standard: shorter keys, faster, and cryptographically stronger than the older RSA 2048. Always use this unless the server is ancient.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-C "you@server-name"&lt;/code&gt; — a comment embedded in the public key. It doesn't affect security, but six months from now when you're looking at a server's &lt;code&gt;authorized_keys&lt;/code&gt; file and see five entries, you'll be grateful you labeled them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-f ~/.ssh/server_name&lt;/code&gt; — the filename. Be precise here. A typo (&lt;code&gt;server-name&lt;/code&gt; vs &lt;code&gt;server_name&lt;/code&gt;) means the next command will fail with "No such file or directory." Run &lt;code&gt;ls ~/.ssh&lt;/code&gt; to confirm the file was created before moving on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When prompted for a passphrase, set one. Your private key file is essentially a master password in a file — if someone copies it off your laptop, the passphrase is the only thing stopping them from using it. macOS Keychain will remember the passphrase for you, so you only type it once per reboot.&lt;/p&gt;
&lt;h3&gt;Step 2 — Copy the public key to the server&lt;/h3&gt;
&lt;p&gt;This is the step that "puts the padlock on the door." You're appending your public key to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on the server — a file SSH checks on every connection attempt.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh-copy-id -i ~/.ssh/server_name.pub user@SERVER_IP&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the &lt;code&gt;.pub&lt;/code&gt; extension — you're sending the public key, not the private one. Your private key (&lt;code&gt;~/.ssh/server_name&lt;/code&gt; without &lt;code&gt;.pub&lt;/code&gt;) never leaves your machine. Ever. If anyone asks you to send them your private key, that's a red flag.&lt;/p&gt;
&lt;p&gt;This command will ask for your password one last time — it needs it to log in and place the key. After this step, you'll never need the password again.&lt;/p&gt;
&lt;h3&gt;Step 3 — Create a named alias in your SSH config&lt;/h3&gt;
&lt;p&gt;You could type &lt;code&gt;ssh -i ~/.ssh/server_name &lt;a href="mailto:user@192.168.1.100"&gt;user@192.168.1.100&lt;/a&gt;&lt;/code&gt; every time. But you won't — you'll forget the flags, use the wrong key, and end up debugging authentication errors at 2am. Instead, write it down once in &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host server-alias&lt;br&gt;
    HostName SERVER_IP&lt;br&gt;
    User your_username&lt;br&gt;
    IdentityFile ~/.ssh/server_name&lt;br&gt;
    IdentitiesOnly yes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The line most tutorials skip explaining: &lt;code&gt;IdentitiesOnly yes&lt;/code&gt;. Without it, SSH will try every key in your &lt;code&gt;~/.ssh/&lt;/code&gt; folder, one by one, before trying the right one. On hardened servers that lock you out after a few failed attempts, this can get you banned by your own SSH client before the correct key is even tried. &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; says: use only the key I specified, nothing else.&lt;/p&gt;
&lt;p&gt;Now test it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh server-alias&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you get a shell prompt, the key is working. Do not proceed to Step 4 until this works.&lt;/p&gt;
&lt;h3&gt;Step 4 — Lock the door: disable password authentication on the server&lt;/h3&gt;
&lt;p&gt;Key login working is not enough. As long as password authentication is enabled, your server is still accepting passwords — and still vulnerable to brute-force attacks. This step removes that attack surface entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before you make any changes: open a second terminal and verify you can still &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;ssh server-alias&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; in.&lt;/strong&gt; If you misconfigure &lt;code&gt;sshd_config&lt;/code&gt; and lock yourself out, your existing session stays alive. That's your lifeline to fix it. Never edit SSH config with only one active session.&lt;/p&gt;
&lt;p&gt;On the server, edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PasswordAuthentication no&lt;br&gt;
PermitRootLogin no&lt;br&gt;
PubkeyAuthentication yes&lt;br&gt;
ChallengeResponseAuthentication no&lt;br&gt;
UsePAM no&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each line matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PasswordAuthentication no&lt;/code&gt; — the main goal. No more passwords accepted.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PermitRootLogin no&lt;/code&gt; — attackers always try root first. Remove the target entirely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ChallengeResponseAuthentication no&lt;/code&gt; and &lt;code&gt;UsePAM no&lt;/code&gt; — these close side doors that can sometimes re-enable password prompts even when &lt;code&gt;PasswordAuthentication&lt;/code&gt; is off. Set all three.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Apply the changes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart sshd&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, from a new terminal on your laptop, run &lt;code&gt;ssh server-alias&lt;/code&gt;. If it connects without asking for a password, you're done. If it asks for a password, something in the config didn't apply — go back and check for typos, then restart sshd again.&lt;/p&gt;
&lt;h3&gt;Step 5 — Add a final layer of protection against bots&lt;/h3&gt;
&lt;p&gt;Even with passwords disabled, bots will hammer port 22 all day long. It wastes resources and pollutes your logs. Two quick fixes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Auto-ban IPs that repeatedly fail authentication&lt;br&gt;
sudo apt install fail2ban -y&lt;br&gt;
sudo systemctl enable fail2ban --now&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And optionally, change the SSH port in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Port 2222&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Changing the port doesn't stop a determined attacker — a port scan finds it in seconds. But it eliminates 99% of automated bot traffic, which never scans beyond port 22. Update your &lt;code&gt;~/.ssh/config&lt;/code&gt; to match:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host server-alias&lt;br&gt;
    HostName SERVER_IP&lt;br&gt;
    User your_username&lt;br&gt;
    IdentityFile ~/.ssh/server_name&lt;br&gt;
    IdentitiesOnly yes&lt;br&gt;
    Port 2222&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;What you've actually built&lt;/h3&gt;
&lt;p&gt;You now have a server that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Only accepts cryptographic proof of identity — no passwords, no brute-force surface&lt;/li&gt;
&lt;li&gt;Refuses root login entirely&lt;/li&gt;
&lt;li&gt;Auto-bans IPs that probe it repeatedly&lt;/li&gt;
&lt;li&gt;Is reachable from your laptop with a single memorable alias&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mental model to take away: your private key is a physical key you never duplicate or hand to anyone. Your public key is a lock you can put on as many doors as you want. The config file is your keychain — it tells SSH which key to use for which door, without you having to think about it every time.&lt;/p&gt;
&lt;p&gt;Do this for every server you ever touch. It takes five minutes and it will save you from the kind of incident that ends careers.&lt;/p&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/mac-ssh-key-authentication-setup-disable-password?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>devops</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Messy Reality: 5 Dead Ends the AI Tutorials Skip</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Wed, 26 Aug 2026 15:57:04 +0000</pubDate>
      <link>https://dev.to/dineshstack/the-messy-reality-5-dead-ends-the-ai-tutorials-skip-4fm3</link>
      <guid>https://dev.to/dineshstack/the-messy-reality-5-dead-ends-the-ai-tutorials-skip-4fm3</guid>
      <description>&lt;p&gt;The bonus 6th part of the series on using Claude AI to run a real production server. Parts 1–5 told the clean story. This one tells the truth: the dead ends, the wrong guesses, and the moments the AI hit a wall — because that's what real AI-assisted work actually looks like.&lt;/p&gt;
&lt;h2&gt;The Messy Reality: 5 Dead Ends the AI Tutorials Skip (Part 6)&lt;/h2&gt;
&lt;p&gt;Every "I used AI to fix my server" post makes it look like a straight line: prompt, answer, done. The five parts before this one are cleaner than the real day was, because teaching needs a clear narrative. But if you're a new developer about to try this yourself, the polished version sets a false expectation — and then the first time your AI session hits a wall, you think you're doing it wrong.&lt;/p&gt;
&lt;p&gt;You're not. Walls are normal. What separates a good outcome from a frustrating one isn't avoiding dead ends — it's how you and the AI recover from them. Here are five real ones from that day, and what each taught me.&lt;/p&gt;
&lt;h3&gt;Dead End 1: The AI Confidently Blamed the Wrong Thing&lt;/h3&gt;
&lt;p&gt;When a deploy failed with an SSH timeout, I asked the AI why. Its first theory was detailed and plausible: a firewall tool called fail2ban had probably banned the deploy server's IP after all my failed password attempts. It even gave me the command to check.&lt;/p&gt;
&lt;p&gt;The command returned: &lt;code&gt;fail2ban: command not found&lt;/code&gt;. &lt;strong&gt;fail2ban wasn't even installed.&lt;/strong&gt; The entire theory was built on an assumption about the server that happened to be wrong.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; AI reasoning is only as good as its assumptions about your system. It wasn't lying — it was reasoning correctly from a false premise. The fix wasn't to trust the confident answer; it was to check it. The real cause turned out to be a transient network blip, found by actually reading the logs instead of accepting the first explanation. When an AI gives you a confident diagnosis, the move is to verify the premise before acting on the conclusion.&lt;/p&gt;
&lt;h3&gt;Dead End 2: The Terminal "Froze" and I Panicked&lt;/h3&gt;
&lt;p&gt;Early on, I pressed a key combination to scroll up through some output, and suddenly my terminal stopped responding. I typed — nothing appeared. For a solid minute I genuinely thought I'd broken the session and lost the AI's work.&lt;/p&gt;
&lt;p&gt;I hadn't. I'd entered tmux's scroll mode, where the keyboard scrolls instead of typing. The escape was a single key: &lt;code&gt;q&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; the scariest-feeling problems are often the most trivial. Nothing was frozen, nothing was lost — I was just in a mode I didn't recognise. Before assuming disaster, ask "am I in a different state than I think?" Half of "it's broken" moments for beginners are actually "it's in a mode you didn't know about."&lt;/p&gt;
&lt;h3&gt;Dead End 3: The AI Couldn't Do the Thing I Asked&lt;/h3&gt;
&lt;p&gt;Several times, the AI simply couldn't run a command — anything needing a root password failed, because its shell had no way to type one interactively. My first reaction was frustration: what good is an AI assistant that can't run &lt;code&gt;sudo&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;But this turned into the single most valuable pattern of the day. Instead of the AI having root power, the workflow became: &lt;strong&gt;the AI writes a careful, verified script; I run it in my own terminal where sudo works; I paste the results back for the AI to check.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; the AI's limitation was actually the safety feature. An AI that couldn't silently run root commands meant I stayed in control of everything that mattered, while still getting the AI's speed on the thinking. What felt like a bug was the guardrail working. Reframe the limitation and it becomes the reason the whole thing is safe.&lt;/p&gt;
&lt;h3&gt;Dead End 4: The Fix Worked, Then Immediately Broke Again&lt;/h3&gt;
&lt;p&gt;While fixing the login, I kept hitting the same class of error — "permission denied" writing a file — over and over, in different places. Fix one, another appears. It felt like whack-a-mole.&lt;/p&gt;
&lt;p&gt;Eventually the pattern clicked (and the AI named it): &lt;strong&gt;the deploy created files as one user, but the app runs as a different user.&lt;/strong&gt; Every file the app tried to write at runtime failed, and I was fixing them one at a time instead of seeing the shared root cause.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; when you're fixing the "same" error repeatedly in different spots, stop patching individual symptoms and look for the structural cause. The real fix wasn't ten &lt;code&gt;chown&lt;/code&gt; commands — it was one reconciliation step in the deploy pipeline so it never happens again. If you're playing whack-a-mole, you're fixing symptoms, not the disease.&lt;/p&gt;
&lt;h3&gt;Dead End 5: The Data Was Perfect, and It Still Didn't Work&lt;/h3&gt;
&lt;p&gt;The final bug was the most maddening. Login worked, but every page returned "403 Forbidden — you don't have permission." So I checked the database: my user had the admin role, the role had all 60 permissions, every link was correct. The data was flawless. And it still failed.&lt;/p&gt;
&lt;p&gt;The answer wasn't in the data at all — it was in the code. A route demanded a permission named &lt;code&gt;manage-users&lt;/code&gt;, but the permission that actually existed was called &lt;code&gt;users.view&lt;/code&gt;. A single naming mismatch. The check was looking for something that was never created.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; "the data looks correct" doesn't mean "the code agrees with the data." When everything should work but doesn't, the bug is often in the gap between two things that were each built correctly but don't match — a name, a version, a guard. That's where pointing the AI at the actual source code (not just the database) cracked it open.&lt;/p&gt;
&lt;h3&gt;What the Messy Version Actually Teaches&lt;/h3&gt;
&lt;p&gt;The clean series shows you what's possible. This one shows you what's normal. Every one of these dead ends ended fine — not because the AI was perfect, but because the process had a recovery move built in: verify the assumption, check the mode, reframe the limitation, find the pattern, read the code. AI didn't remove the friction of real engineering. It gave me a faster, sharper partner to work through the friction with.&lt;/p&gt;
&lt;p&gt;If you take one thing from this whole series into your own AI-assisted work, let it be this: &lt;strong&gt;the AI is a brilliant collaborator, not an oracle.&lt;/strong&gt; It will sometimes be confidently wrong, occasionally unable to act, and frequently one step away from the answer rather than holding it outright. Treat it like the sharpest junior engineer you've ever worked with — check its assumptions, keep the keys, and you'll get results that neither of you would reach alone.&lt;/p&gt;
&lt;h3&gt;Key Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A confident AI answer can rest on a false assumption — verify the premise before acting.&lt;/li&gt;
&lt;li&gt;The scariest-feeling problems (a "frozen" terminal) are often trivial state confusion.&lt;/li&gt;
&lt;li&gt;The AI's inability to run root commands is a safety feature, not a flaw — it keeps you in control.&lt;/li&gt;
&lt;li&gt;Repeating the same error in different places means you're patching symptoms; find the structural cause.&lt;/li&gt;
&lt;li&gt;"The data is correct" and "the code works" are different claims — bugs hide in the gap between them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's the honest end of the series. If the clean parts inspired you and this messy one reassured you, it's done its job. What's a dead end you hit that turned out to be a one-line fix? Share it in the comments — the war stories are where we all actually learn.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/ai-server-dead-ends-messy-reality?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Login That Never Worked: Debugging 5 Layers Deep</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Mon, 24 Aug 2026 18:54:04 +0000</pubDate>
      <link>https://dev.to/dineshstack/the-login-that-never-worked-debugging-5-layers-deep-3knc</link>
      <guid>https://dev.to/dineshstack/the-login-that-never-worked-debugging-5-layers-deep-3knc</guid>
      <description>&lt;p&gt;Part 5, the finale of a 5-part series on using Claude AI to run, secure, and ship a real production server. The pipeline shipped (Part 4). Then I tried to log in.&lt;/p&gt;
&lt;h2&gt;The Login That Never Worked — Debugging Five Layers Deep with AI (Part 5)&lt;/h2&gt;
&lt;p&gt;The pipeline was green. Both releases were live. I opened my SaaS to see it working — and got a 502. Then, after fixing that, a "CORS error." Then a 500. Then an OAuth failure. Then a 403. Each fix revealed the next problem underneath, like peeling an onion that makes you cry five times.&lt;/p&gt;
&lt;p&gt;This is the most instructive post in the series, because it shows what AI debugging actually looks like on a real system: &lt;strong&gt;not one magic answer, but a disciplined peeling of layers&lt;/strong&gt;, where each error's real cause is hidden behind a misleading symptom. Here's all five, in order.&lt;/p&gt;
&lt;h3&gt;Layer 1: The 502 — A Stopped Process&lt;/h3&gt;
&lt;p&gt;The frontend returned &lt;code&gt;502 Bad Gateway&lt;/code&gt;. nginx proxies the site to a Node process on port 3003, and Claude's first check — &lt;code&gt;pm2 list&lt;/code&gt; — showed the &lt;code&gt;visa-saas&lt;/code&gt; process was &lt;strong&gt;stopped&lt;/strong&gt;. The deploy's process-reload had left it in a bad state.&lt;/p&gt;
&lt;p&gt;The fix wasn't a blind restart (that can hide a crash loop). Claude had me delete the corrupted process entry and start it fresh:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pm2 delete visa-saas
pm2 start ecosystem.config.js
pm2 save   # so it survives reboots&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Process online, port 3003 answering. &lt;strong&gt;Lesson learned:&lt;/strong&gt; the deploy's "reload" logic couldn't recover a stopped process — a real pipeline bug we noted for a follow-up fix. A green deploy that leaves the site down is worse than one that fails honestly.&lt;/p&gt;
&lt;h3&gt;Layer 2: The "CORS Error" That Wasn't CORS&lt;/h3&gt;
&lt;p&gt;Now the site loaded but login threw what the browser called a CORS error. Here's the trap Claude flagged that saves hours: &lt;strong&gt;when a Laravel API throws a 500, the error response often has no CORS headers — so the browser reports "CORS policy" when the real problem is the API crashing.&lt;/strong&gt; The CORS message was a symptom, not the disease.&lt;/p&gt;
&lt;p&gt;And the disease connected back to our Part 3 work. Remember the storage-permissions fix? On this new release, the storage folder was owned by the deploy user, but PHP-FPM runs as the web user — so PHP couldn't write logs or sessions, threw 500s, and the browser painted them as CORS. Claude spotted it because it read the actual error, not the browser's guess. One &lt;code&gt;chown&lt;/code&gt; and the 500s vanished.&lt;/p&gt;
&lt;h3&gt;Layer 3: The OAuth Failure — A Wrong Client ID&lt;/h3&gt;
&lt;p&gt;Login now reached the API and returned a precise JSON error (progress — a clean error means CORS is genuinely fine):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;"Client authentication failed"
League\OAuth2\Server\Exception\OAuthServerException&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Laravel Passport couldn't issue a token. Claude traced it into the database. There were two OAuth clients:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Password Grant Client&lt;/td&gt;
&lt;td&gt;personal = 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Personal Access Client&lt;/td&gt;
&lt;td&gt;personal = 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;But the app's config pointed &lt;code&gt;PASSPORT_PERSONAL_ACCESS_CLIENT_ID=1&lt;/code&gt; — the wrong type. It was trying to issue a personal access token using a password-grant client. Changing the ID to &lt;code&gt;2&lt;/code&gt; and rebuilding the config cache fixed it. Almost certainly a bug that had existed since setup — because with login broken, &lt;strong&gt;nobody had ever successfully authenticated on this deployment&lt;/strong&gt;. Our work didn't break it; it was the first time anyone got far enough to hit it.&lt;/p&gt;
&lt;h3&gt;Layer 4: The Permission-Write 500s&lt;/h3&gt;
&lt;p&gt;Logged in! Then the dashboard threw 500s on loading modules. Same root cause as Layer 2, different file: the app writes a &lt;code&gt;modules_statuses.json&lt;/code&gt; at runtime, owned by the wrong user, so the web process couldn't write it. Another targeted &lt;code&gt;chown&lt;/code&gt;. This was the moment the deeper pattern became clear: &lt;strong&gt;the deploy creates files as one user, the app runs as another&lt;/strong&gt; — the real fix is a permission-reconciliation step in the pipeline itself, so every future deploy doesn't reintroduce it.&lt;/p&gt;
&lt;h3&gt;Layer 5: The 403 — A One-Word Typo&lt;/h3&gt;
&lt;p&gt;The final boss. Every data endpoint returned &lt;code&gt;403 Forbidden — "You do not have the required permissions."&lt;/code&gt; But I was logged in as a super-admin. Claude checked the database: my user had the super-admin role, the role had all 60 permissions, everything linked correctly. The data was perfect. So why 403?&lt;/p&gt;
&lt;p&gt;This is where I pointed Claude Code at the actual codebase, and it traced the exact chain. The route guarding &lt;code&gt;/users&lt;/code&gt; required a permission named &lt;code&gt;manage-users&lt;/code&gt;. But the seeder that creates permissions created &lt;code&gt;users.view&lt;/code&gt; — &lt;strong&gt;the permission name the route demanded had never been created.&lt;/strong&gt; A single naming mismatch between the route and the seeder. Every user in the system was locked out of that endpoint, and had been forever.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Route wants:    permission:manage-users   ← doesn't exist
Seeder creates: users.view                ← the real name&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fix: change the route guard to the permission that actually exists. And because AI is good at exactly this kind of grind, I had it grep the entire route file for other mismatches — so we could fix them all in one pass instead of discovering them one 403 at a time.&lt;/p&gt;
&lt;h3&gt;Why This Story Matters More Than a Clean Success&lt;/h3&gt;
&lt;p&gt;Five layers. A blank page that was a stopped process. A CORS error that was a permissions problem. An OAuth failure that was a config typo. A 403 that was a naming mismatch. &lt;strong&gt;Not one of these symptoms pointed at its own cause.&lt;/strong&gt; That's what real debugging is — and it's exactly where an AI partner earns its place: reading the actual error instead of the misleading one, tracing a symptom to its true source, and grepping a codebase for every instance of a bug in seconds.&lt;/p&gt;
&lt;p&gt;The AI didn't replace my judgment — I decided what to fix by hand, what to route through the pipeline, and when to stop and think. But it turned a bewildering cascade into a solvable sequence. And critically: bugs 3, 4, and 5 were &lt;strong&gt;pre-existing&lt;/strong&gt; — they'd been in that codebase since setup, invisible because nobody could log in to trigger them. We didn't cause them. We were the first to reach them, and the AI helped root-cause each one instead of flailing.&lt;/p&gt;
&lt;h3&gt;The Whole Journey, Start to Finish&lt;/h3&gt;
&lt;p&gt;Over one day, with Claude AI as an investigate-and-verify partner, this server went from: an internet-exposed financial API, world-readable secrets across 20 projects, password-based SSH open to the world, no deployment pipeline, and a frontend that had never worked — to: a locked-down box, key-only SSH, a zero-downtime CI/CD pipeline, and a working login on a live dashboard.&lt;/p&gt;
&lt;h3&gt;Key Takeaways for New Developers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;The symptom is almost never the cause. A 502, a CORS error, and a 403 all lied about what was really wrong.&lt;/li&gt;
&lt;li&gt;Read the actual error, not the browser's interpretation of it.&lt;/li&gt;
&lt;li&gt;AI is exceptional at tracing a symptom to its source and grepping a whole codebase for every instance of a bug.&lt;/li&gt;
&lt;li&gt;Keep the human in charge of what to fix and where it belongs (hand-fix vs. pipeline); let the AI do the tracing and the grind.&lt;/li&gt;
&lt;li&gt;A working system revealing old bugs isn't a regression — it's progress. You can only find the login bug once login gets far enough to fail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's the series. One developer, one AI partner, one very real production server — audited, secured, shipped, and debugged, the honest way, with a paper trail at every step. If you're nervous about letting AI near your infrastructure, I hope this showed you the pattern that makes it not just safe but genuinely powerful: &lt;strong&gt;the AI gets the brains, you keep the keys.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Thanks for reading all five parts. If this helped, the whole series is built to be followed step by step on your own server. What would you point an AI agent at first — a security audit, or that one bug you've been avoiding?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/debugging-five-layers-deep-with-ai?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>backend</category>
      <category>claude</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How to Build a Zero-Downtime CI/CD Pipeline with an AI Pair</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Sat, 22 Aug 2026 07:52:06 +0000</pubDate>
      <link>https://dev.to/dineshstack/how-to-build-a-zero-downtime-cicd-pipeline-with-an-ai-pair-2b3c</link>
      <guid>https://dev.to/dineshstack/how-to-build-a-zero-downtime-cicd-pipeline-with-an-ai-pair-2b3c</guid>
      <description>&lt;p&gt;Part 4 of a 5-part series on using Claude AI to run, secure, and ship a real production server. The server is now secure (Parts 1–3). Time to ship code the right way.&lt;/p&gt;
&lt;h2&gt;Build a Zero-Downtime CI/CD Pipeline with an AI Pair (Part 4)&lt;/h2&gt;
&lt;p&gt;Up to now I'd been deploying by hand — SSH in, pull, hope. For my main SaaS project I wanted the real thing: push to GitHub, tests run automatically, and if they pass, the server swaps to a fresh release with a symlink so rollback is instant. In this post, Claude plays two roles: &lt;strong&gt;code-review partner&lt;/strong&gt; for my pipeline, and &lt;strong&gt;server-prep engineer&lt;/strong&gt; to get the box ready — using the same investigate-then-execute pattern from the earlier parts.&lt;/p&gt;
&lt;h3&gt;The Architecture (Capistrano-Style Releases)&lt;/h3&gt;
&lt;p&gt;The idea is simple and powerful. Instead of overwriting your live app in place, each deploy lands in a timestamped folder, and a symlink points to the current one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/var/www/visa-saas/
├── releases/
│   ├── 20260709224220/   ← previous release
│   └── 20260712172016/   ← new release
├── shared/               ← .env, storage (survive across deploys)
├── api  → releases/20260712172016   ← symlink, flipped atomically
└── web  → releases-web/...           ← same idea for the frontend&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deploy = build a new release folder, then flip the symlink. Rollback = flip the symlink back. That's the whole magic: &lt;strong&gt;rollback becomes one command instead of a panic.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Claude as Code Reviewer: What It Caught in My Pipeline&lt;/h3&gt;
&lt;p&gt;I had draft GitHub Actions workflows and asked Claude to review them like a senior engineer. It found real problems I'd have shipped:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Issue Claude flagged&lt;/th&gt;
&lt;th&gt;Why it mattered&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web deploy did &lt;code&gt;rm -rf&lt;/code&gt; then move — no rollback&lt;/td&gt;
&lt;td&gt;The old release was destroyed at activation. One bad build = no way back.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prune step would eventually delete the original backup&lt;/td&gt;
&lt;td&gt;"Keep 5 newest" quietly wipes your &lt;code&gt;initial&lt;/code&gt; safety copy after 5 deploys.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare &lt;code&gt;php&lt;/code&gt; on a server with 6 PHP versions&lt;/td&gt;
&lt;td&gt;Migrations could run under the wrong PHP than the site serves.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations with no database backup first&lt;/td&gt;
&lt;td&gt;A symlink rollback can't undo a schema change — you need a dump.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party Actions pinned to tags, not commit SHAs&lt;/td&gt;
&lt;td&gt;A moved tag could inject malicious code into a job that holds your SSH key.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;It also caught the classic PHP-plus-symlink gotcha: without the right nginx setting (&lt;code&gt;$realpath_root&lt;/code&gt;), OPcache can keep serving old code after you flip the symlink. That's the kind of subtle, experience-earned detail that makes an AI reviewer genuinely valuable — not because it's magic, but because it's read every variation of this mistake.&lt;/p&gt;
&lt;h3&gt;The Security Angle Nobody Writes Down&lt;/h3&gt;
&lt;p&gt;One point Claude raised that I hadn't considered: &lt;strong&gt;once GitHub Actions can SSH into production, anyone who can push a workflow change can run code on your server.&lt;/strong&gt; So the deploy key got its own dedicated keypair, restricted in &lt;code&gt;authorized_keys&lt;/code&gt; (&lt;code&gt;no-agent-forwarding,no-port-forwarding&lt;/code&gt;), and the deploy job was gated behind a protected GitHub environment. Your CI is only a safety gate if nothing can route around it.&lt;/p&gt;
&lt;h3&gt;Server Prep: Investigate, Then One Script&lt;/h3&gt;
&lt;p&gt;Before merging, the server needed preparing — release directories, a database-backup folder, the nginx storage path, a narrow passwordless-sudo rule for reloading PHP. True to the pattern, Claude did a &lt;strong&gt;read-only readiness report first&lt;/strong&gt;, and it changed the plan: three things I'd assumed needed fixing were already correct. Had we skipped straight to scripting, we'd have installed a duplicate sudo rule and "fixed" a setting that was already right. The verify-first phase paid for itself again.&lt;/p&gt;
&lt;p&gt;Then Claude generated one prep script with its now-familiar signature: timestamped backups, diff checkpoints, per-step verification, and printed (never auto-run) rollback commands. It even excluded Passport's private keys from a bulk permission change so they'd stay locked at &lt;code&gt;600&lt;/code&gt; — a detail I'd have missed.&lt;/p&gt;
&lt;h3&gt;Merge, and the First Real Deploy&lt;/h3&gt;
&lt;p&gt;All checks green, PR merged. Both API and web pipelines fired. Watching the symlinks flip in real time was the payoff:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;watch -n 2 'readlink /var/www/visa-saas/api; readlink /var/www/visa-saas/web'
# api  → releases/20260712172016
# web  → releases-web/20260712174334   ← it flipped!&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The First Failure (And Why It Didn't Matter)&lt;/h3&gt;
&lt;p&gt;The web deploy failed on its first attempt — an SSH &lt;code&gt;i/o timeout&lt;/code&gt; uploading the release. My first instinct was "the hardening broke CI." But we checked the logs instead of guessing: the deploy key authenticated fine; it was just a transient network blip on one job. A re-run sailed through.&lt;/p&gt;
&lt;p&gt;The crucial point: &lt;strong&gt;because of the release architecture, the site never went down during that failure.&lt;/strong&gt; The old release stayed live until the new one was ready to flip. A failed deploy on this design is a non-event, not an outage. That safety net is the entire reason to build it this way.&lt;/p&gt;
&lt;h3&gt;Key Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Timestamped releases + a symlink flip turn rollback from a crisis into one command.&lt;/li&gt;
&lt;li&gt;An AI code reviewer shines at catching the subtle, experience-earned mistakes — missing backups, wrong PHP, OPcache traps, supply-chain risks.&lt;/li&gt;
&lt;li&gt;When CI can reach production, treat the deploy key like the sensitive credential it is.&lt;/li&gt;
&lt;li&gt;Investigate-then-script saved us from "fixing" things that were already fine — twice.&lt;/li&gt;
&lt;li&gt;A good pipeline fails safely: the live site stays up until the new release is proven.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pipeline worked. Both releases were live. And then I opened the site to admire it — and the login was broken. Not a little broken. A cascading, five-layers-deep broken that started as a blank page and ended at a one-word typo buried in a route file. That debugging journey — the most instructive part of the whole day — is &lt;strong&gt;Part 5&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;👉 Coming up in Part 5: "The Login That Never Worked — Debugging Five Layers Deep with AI." What's the worst production bug you've shipped through a green pipeline?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/zero-downtime-cicd-pipeline-ai-pair?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>deployment</category>
      <category>devops</category>
    </item>
    <item>
      <title>Laravel queue jobs not processing: wrong connection</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Fri, 21 Aug 2026 11:44:04 +0000</pubDate>
      <link>https://dev.to/dineshstack/laravel-queue-jobs-not-processing-wrong-connection-62b</link>
      <guid>https://dev.to/dineshstack/laravel-queue-jobs-not-processing-wrong-connection-62b</guid>
      <description>&lt;p&gt;&lt;strong&gt;If your jobs table is growing while the queue worker sits there reporting healthy, the two processes are reading and writing different queues.&lt;/strong&gt; Laravel lets you set the connection in two places, and the one in the worker's command line silently wins over the one in your environment file.&lt;/p&gt;
&lt;p&gt;Ours disagreed for nineteen days. It cost 55,470 orphaned jobs, every transactional email in that window, and — the part I find hardest to defend — nobody noticed, because from every angle the system looked fine.&lt;/p&gt;
&lt;h2&gt;What we saw&lt;/h2&gt;
&lt;p&gt;We were auditing a production database before an unrelated migration when a count came back wrong:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT COUNT(*) FROM jobs;
-- 56588&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fifty-six thousand pending jobs. The oldest was nineteen days old. The newest was thirty seconds old and there was a fresh one every thirty seconds, forever.&lt;/p&gt;
&lt;p&gt;The queue worker container had been up for weeks. Its logs showed a clean supervisord boot and nothing else — no errors, no warnings, no processed jobs. Every health check passed. The application served traffic normally. Customers were completing orders.&lt;/p&gt;
&lt;p&gt;The arithmetic told us where to look before the configuration did. One job class accounted for 55,470 of the rows, and the scheduler dispatched it every thirty seconds:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2 per minute × 60 × 24 = 2,880 per day
2,880 × 19 days ≈ 54,720&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is not a job failing and retrying. That is a job being enqueued perfectly and never once being read, since the day the scheduler entry went live.&lt;/p&gt;
&lt;h2&gt;The two places a queue connection is set&lt;/h2&gt;
&lt;p&gt;The environment file said one thing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;QUEUE_CONNECTION=database&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worker's container definition said another, hardcoded months earlier and never revisited:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SUPERVISOR_PHP_COMMAND: "php /var/www/html/artisan queue:work redis
  --queue=high,default,low --sleep=3 --tries=3 --max-time=3600"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That first argument to &lt;code&gt;queue:work&lt;/code&gt; is the connection name, and &lt;strong&gt;it overrides &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;QUEUE_CONNECTION&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; completely.&lt;/strong&gt; The behaviour is documented and genuinely useful — it is how you run separate workers against separate backends. It is dangerous only because it is set in a different file, in a different repository concern, from the value it overrides.&lt;/p&gt;
&lt;p&gt;So the application dispatched jobs into MySQL. The worker polled Redis, found nothing, slept three seconds, and polled again. It did that several million times without complaint.&lt;/p&gt;
&lt;h2&gt;Why nothing logged an error&lt;/h2&gt;
&lt;p&gt;This is the part worth internalising, because it generalises far beyond queues.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nothing failed.&lt;/strong&gt; Dispatch wrote a row and returned success — that is what dispatch does. The worker blocked on an empty list and returned success — that is what polling an empty queue does. An error requires some component to attempt something impossible, and neither component ever attempted anything impossible. Each half was working correctly. The system was broken only in the relationship between them, and nothing in the stack is responsible for that relationship.&lt;/p&gt;
&lt;p&gt;Every monitor we had was pointed at a component. Container health: passing. Error rate: zero. Database: fine. Not one of them was pointed at the contract.&lt;/p&gt;
&lt;h2&gt;Why the damage was smaller than it should have been&lt;/h2&gt;
&lt;p&gt;The dominant job class was an outbox recovery job — a safety net that re-publishes events whose delivery was not confirmed after the transaction committed. So we checked what it would have had to recover:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT status, COUNT(*) FROM outbox_messages GROUP BY status;
-- sent  1668&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every row sent. Zero pending. The primary publish path had a perfect record for the entire window, which is why nineteen days of a dead safety net produced no visible symptom.&lt;/p&gt;
&lt;p&gt;We got away with it. Read that sentence as the accusation it is: &lt;strong&gt;we did not detect the failure, we were rescued by the fact that the thing it protected never needed protecting.&lt;/strong&gt; Had the primary path faltered once during those nineteen days, the recovery mechanism would have been sitting in a MySQL table watching it happen.&lt;/p&gt;
&lt;p&gt;The rest of the backlog was less lucky. Several hundred registration notifications and booking-status emails were in there. Those never sent, and nobody filed a ticket — which tells you something uncomfortable about how much of that mail anyone was reading.&lt;/p&gt;
&lt;h2&gt;Clearing it: why we did not just release the backlog&lt;/h2&gt;
&lt;p&gt;The instinct on finding 56,000 stuck jobs is to point a worker at them and let them drain. We deliberately did not.&lt;/p&gt;
&lt;p&gt;Those jobs were nineteen days stale. Releasing them would have delivered hundreds of "welcome, you've registered" emails to people who registered three weeks ago, and status updates for rides that finished long before. &lt;strong&gt;Delivering a stale message is a worse outcome than never delivering it&lt;/strong&gt;, and unlike the silent failure, customers would definitely have noticed that one.&lt;/p&gt;
&lt;p&gt;So: group by class, decide per class, back up, then discard.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysqldump --single-transaction app_prod jobs failed_jobs | gzip &amp;gt; jobs-backup.sql.gz
TRUNCATE TABLE jobs;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The backup makes the decision reversible for the cost of a few megabytes. Take it even when you are confident, because the confidence is about the job classes you identified, not the ones you skimmed past.&lt;/p&gt;
&lt;p&gt;Then the fix, which was one line — pointing the application at the connection the worker had been watching all along. Jobs began clearing in two to four milliseconds each.&lt;/p&gt;
&lt;h2&gt;The same bug is not equally dangerous everywhere&lt;/h2&gt;
&lt;p&gt;Here is the detail that changes how you should weight this. We fixed the mismatch as part of moving the queue onto Redis, and that move altered the failure's blast radius entirely.&lt;/p&gt;
&lt;p&gt;In MySQL, 56,000 orphaned jobs were a large table on a disk with hundreds of gigabytes free. Genuinely harmless — which is precisely why it survived nineteen days.&lt;/p&gt;
&lt;p&gt;On Redis, the identical bug consumes &lt;strong&gt;memory&lt;/strong&gt;, and queue entries carry no TTL. They sit there until a worker takes them. On a shared box without swap, unbounded memory growth does not politely degrade; it reaches a limit and something gets killed, and the process the kernel selects is chosen by size rather than by blame — frequently your database rather than the cache that caused it.&lt;/p&gt;
&lt;p&gt;Same misconfiguration, same silence, radically different consequence. &lt;strong&gt;Moving a queue to a faster substrate also moves it to a less forgiving one.&lt;/strong&gt; If you are making that migration, fix your queue-depth monitoring first, not afterwards.&lt;/p&gt;
&lt;h2&gt;You cannot alert on a metric that does not exist&lt;/h2&gt;
&lt;p&gt;The obvious follow-up is an alert on queue depth. We went to add one and found the gap was one level deeper than expected.&lt;/p&gt;
&lt;p&gt;The Redis exporter reports totals — memory, client count, keys per database — but it does &lt;strong&gt;not&lt;/strong&gt; publish the length of any individual list unless you name it explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;REDIS_EXPORTER_CHECK_KEYS: "queues:*"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Until that line existed there was no queue-depth metric in Prometheus at all. A dashboard would have shown a healthy Redis for all nineteen days, because every metric it displayed was genuinely healthy. &lt;strong&gt;An absent metric and a good metric look identical on a graph.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The alert we wrote was wrong, and testing caught it&lt;/h2&gt;
&lt;p&gt;A healthy queue drains in milliseconds, so any depth that survives a long window means nothing is consuming it. That was the rule:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;min_over_time(redis_key_size{key=~"queues:.*"}[30m]) &amp;gt; 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We tested it by planting a synthetic backlog. It went pending, correctly. Then we deleted the key — and it stayed pending.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;min_over_time&lt;/code&gt; keeps returning samples from its whole window after a key disappears. So any burst that got scraped once would fire this five minutes later and hold it for half an hour: exactly the false-positive noise that teaches a team to ignore alerts. Pairing it with a check for a currently-present sample fixes it, because an emptied Laravel queue deletes its Redis list and the series simply stops:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;redis_key_size{key=~"queues:.*"} &amp;gt; 10
  and
min_over_time(redis_key_size{key=~"queues:.*"}[30m]) &amp;gt; 10&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Re-tested: pending with a backlog, inactive the moment it drains. &lt;strong&gt;An alert is a piece of production code, and an untested one is likelier to erode trust than to protect anything.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;What to check on your own system&lt;/h2&gt;
&lt;p&gt;Two commands, worth running now rather than during an incident. What the application believes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php artisan tinker --execute="echo config('queue.default');"&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And what the worker is actually executing — read the process, not the config file that you believe produced it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec app_queue ps aux | grep queue:work&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If those two disagree, you have this bug, and your logs will not tell you. Then confirm something is genuinely draining, rather than that nothing has arrived: a depth of zero and a broken consumer look the same from outside.&lt;/p&gt;
&lt;h2&gt;The principle&lt;/h2&gt;
&lt;p&gt;Health checks verify components. This failure lived in the space between two healthy components, where nothing was looking, and the only honest signal available was a number that nobody was collecting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For any handoff between two processes, monitor the queue between them rather than the processes themselves.&lt;/strong&gt; Depth over time is the cheapest true statement you can make about a distributed system: it goes up when the producer outruns the consumer, and it stays up when the consumer is gone. Neither of those facts is visible from either end alone.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/laravel-queue-worker-wrong-connection?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>debugging</category>
      <category>laravel</category>
      <category>php</category>
    </item>
    <item>
      <title>Locking Down Secrets and SSH with AI (and the Cloud-Init Trap)</title>
      <dc:creator>Dinesh Wijethunga</dc:creator>
      <pubDate>Thu, 20 Aug 2026 11:18:04 +0000</pubDate>
      <link>https://dev.to/dineshstack/locking-down-secrets-and-ssh-with-ai-and-the-cloud-init-trap-305n</link>
      <guid>https://dev.to/dineshstack/locking-down-secrets-and-ssh-with-ai-and-the-cloud-init-trap-305n</guid>
      <description>&lt;p&gt;Part 3 of a 5-part series on using Claude AI to run, secure, and ship a real production server. Part 2 closed an exposed API. Now we tackle the biggest structural risk the audit found — and lock the front door.&lt;/p&gt;
&lt;h2&gt;Locking Down Secrets and SSH with AI — and the Trap That Almost Fooled Us (Part 3)&lt;/h2&gt;
&lt;p&gt;The audit's number-one risk wasn't dramatic, but it was the scariest: across nearly 20 projects, the &lt;code&gt;.env&lt;/code&gt; files — the ones holding database passwords and API keys — were &lt;strong&gt;world-readable&lt;/strong&gt;. Any process, any local user, any path-traversal bug in the weakest app could read every other client's secrets. This post is how Claude and I fixed all of them at once, then shut off password-based SSH entirely — and hit a trap that silently tried to undo the whole thing.&lt;/p&gt;
&lt;h3&gt;Why World-Readable .env Files Are a Slow-Motion Disaster&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; at mode &lt;code&gt;644&lt;/code&gt; means "owner can write, everyone can read." On a server with one project, that's sloppy. On a server with 20 unrelated client projects sharing a web user, it means a single vulnerability anywhere gives an attacker every tenant's credentials. The fix is simple — &lt;code&gt;640&lt;/code&gt;, owned by the right user — but doing it across 20 live sites without breaking any of them takes care.&lt;/p&gt;
&lt;h3&gt;Claude's Approach: One Script, Verify Every Step&lt;/h3&gt;
&lt;p&gt;I asked Claude to write a single script following the same safety pattern from Part 2: back up, change, verify, and — crucially — &lt;strong&gt;check every website still works after each change&lt;/strong&gt;. The clever part it added on its own: a baseline pass that curls every site before touching anything, so a site that was already broken wouldn't be misreported as something the script broke.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# For each project: capture current perms, fix, verify the live site
for project in "${PROJECTS[@]}"; do
  # backup-aware: record old mode/owner first
  sudo chown www-data:www-data "$project/.env"
  sudo chmod 640 "$project/.env"
  # then curl the site and compare against the pre-change baseline
done&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude also caught two things a blanket script would have broken:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js apps under pm2&lt;/strong&gt; run as a different user than PHP apps — chowning their &lt;code&gt;.env&lt;/code&gt; to the web user would lock out the process that reads it. Claude special-cased those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One .env shared by two apps&lt;/strong&gt; (a Laravel API and a Next.js frontend) needed split ownership so both could still read it while dropping public access.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's the difference between "AI runs a chmod loop" and "AI understands the runtime." It ran across 18 projects plus a world-writable storage directory and a trading-bot secret — every one verified against baseline, zero regressions.&lt;/p&gt;
&lt;h3&gt;The Bonus: Baseline Checks Found Pre-Existing Problems&lt;/h3&gt;
&lt;p&gt;The baseline pass earned its keep immediately. Before changing a thing, it revealed three sites that were already broken — two APIs returning 500 errors and one domain that wouldn't connect at all. None caused by the script; all surfaced by it. That's a lovely side effect of doing things carefully: you discover problems you didn't know you had.&lt;/p&gt;
&lt;h3&gt;Then: Locking SSH to Keys Only&lt;/h3&gt;
&lt;p&gt;Next, the front door. The server still accepted &lt;strong&gt;password logins from the entire internet&lt;/strong&gt; — meaning it was brute-forceable. The plan: install a personal SSH key, prove it works, then disable passwords. Order matters, because a mistake here locks you out of your own server.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. On my Mac: create a key if I didn't have one
ssh-keygen -t ed25519

# 2. Install it on the server
ssh-copy-id deploy_user@SERVER_IP

# 3. PROVE key auth works BEFORE disabling passwords
ssh -o PasswordAuthentication=no deploy_user@SERVER_IP&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Only after that login succeeded did we disable passwords in &lt;code&gt;sshd_config&lt;/code&gt;. And this is where the trap sprang.&lt;/p&gt;
&lt;h3&gt;⚠ The Cloud-Init Trap That Almost Fooled Us&lt;/h3&gt;
&lt;p&gt;I set &lt;code&gt;PasswordAuthentication no&lt;/code&gt; in the main SSH config. Clean. Done, right? Claude insisted on one more check — grepping all the config, including the &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt; drop-in directory. And there it was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/etc/ssh/sshd_config:65:            PasswordAuthentication no
/etc/ssh/sshd_config.d/50-cloud-init.conf:1: PasswordAuthentication yes   ← !!!&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A cloud-init drop-in file was setting &lt;code&gt;PasswordAuthentication yes&lt;/code&gt; — and here's the killer detail: &lt;strong&gt;SSH reads the "Include" directive near the top of the config, so the drop-in file's setting wins over the main file.&lt;/strong&gt; Without catching this, my "hardening" would have changed nothing. Password auth would have stayed wide open while I believed it was closed. Claude also caught that root login was still enabled and flagged that too.&lt;/p&gt;
&lt;p&gt;We fixed both files, validated with &lt;code&gt;sshd -t&lt;/code&gt; (test before reload, like &lt;code&gt;nginx -t&lt;/code&gt;), reloaded, and then ran the two-sided proof from my laptop:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh deploy_user@SERVER_IP 'echo OK'          # key works
ssh -o PubkeyAuthentication=no deploy_user@SERVER_IP   # password refused
ssh root@SERVER_IP                            # root refused&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Result: key works, password bounces, root bounces. Three for three.&lt;/p&gt;
&lt;h3&gt;The Deeper Lesson: Verify the Baseline, Not Just Your Changes&lt;/h3&gt;
&lt;p&gt;This was the second time that day a "should already be hardened" assumption turned out false when Claude actually checked. That's the real discipline this whole experience taught me: &lt;strong&gt;verify-don't-assume applies to the starting state, not just to the changes you make.&lt;/strong&gt; The cloud-init file had been silently overriding intent for who knows how long. An AI that grepped everything instead of trusting the obvious file is what caught it.&lt;/p&gt;
&lt;h3&gt;Key Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;World-readable &lt;code&gt;.env&lt;/code&gt; files are the quiet #1 risk on a multi-project box. Fix to &lt;code&gt;640&lt;/code&gt;, but verify each site still runs after.&lt;/li&gt;
&lt;li&gt;Always baseline before a bulk change, so you can tell your breakage apart from pre-existing breakage.&lt;/li&gt;
&lt;li&gt;Install and prove your SSH key before disabling passwords — keep your current session open as a lifeline.&lt;/li&gt;
&lt;li&gt;On Ubuntu, always grep &lt;code&gt;sshd_config.d/&lt;/code&gt; — a drop-in file can silently override your main config.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The server was now genuinely locked down: secrets protected, SSH key-only, root disabled — all verified from outside. With the house secure, it was time to build something: a proper CI/CD pipeline so I could ship code changes with zero downtime and one-command rollbacks. That's &lt;strong&gt;Part 4&lt;/strong&gt;, where Claude becomes a code-review partner and we ship a real deployment pipeline — and hit our first live deploy failure.&lt;/p&gt;
&lt;p&gt;👉 Coming up in Part 4: "Building a Zero-Downtime CI/CD Pipeline with an AI Pair." Run &lt;code&gt;sudo grep -r PasswordAuthentication /etc/ssh/&lt;/code&gt; right now — are you sure it says what you think it does?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://dineshstack.com/en/secrets-ssh-hardening-ai-cloud-init-trap?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;dineshstack.com&lt;/a&gt; — read the full version with code samples and updates there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>linux</category>
      <category>security</category>
    </item>
  </channel>
</rss>
