<?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: shape93</title>
    <description>The latest articles on DEV Community by shape93 (@shape93).</description>
    <link>https://dev.to/shape93</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F852600%2Fbe654915-b95a-4729-987f-f8b99fb786db.jpeg</url>
      <title>DEV Community: shape93</title>
      <link>https://dev.to/shape93</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shape93"/>
    <language>en</language>
    <item>
      <title>Automating Docker Deployments with GitHub Actions, Cloudflare Tunnels, and Portainer</title>
      <dc:creator>shape93</dc:creator>
      <pubDate>Wed, 23 Apr 2025 10:30:18 +0000</pubDate>
      <link>https://dev.to/shape93/automating-docker-deployments-with-github-actions-cloudflare-tunnels-and-portainer-2phm</link>
      <guid>https://dev.to/shape93/automating-docker-deployments-with-github-actions-cloudflare-tunnels-and-portainer-2phm</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4wquvsjevpzketa524cy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4wquvsjevpzketa524cy.png" alt="Image description" width="800" height="353"&gt;&lt;/a&gt;&lt;br&gt;
During these Easter holidays, I found myself debating whether to experiment with my self-hosted home lab setup. Spoiler alert: I did.&lt;br&gt;&lt;br&gt;
When I started this journey years ago, the possibilities seemed endless—even without top-tier hardware. But as my Docker services multiplied, managing them became messy. Without proper version control, backups, and orchestration, things spiraled quickly. Enter &lt;strong&gt;webhook-driven deployments&lt;/strong&gt;—a flexible approach using GitHub Actions, Cloudflare Tunnels, and Portainer. Let’s dive in!&lt;/p&gt;


&lt;h2&gt;
  
  
  ⚠️ Disclaimers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Tunnels&lt;/strong&gt;: This guide assumes you’ve already set up Cloudflare Tunnels to expose services remotely.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portainer Business Edition&lt;/strong&gt;: Required for GitOps/webhook features. You can &lt;a href="https://www.portainer.io/take-3" rel="noopener noreferrer"&gt;get a free license&lt;/a&gt; for small setups (up to 3 nodes).&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Step 1: Setting Up the GitHub Repository
&lt;/h2&gt;

&lt;p&gt;Start by creating a GitHub repository (private or public—sensitive data will use secrets). Clone it locally or use GitHub Codespaces for editing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fttdx05idl7ptetsbxdk9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fttdx05idl7ptetsbxdk9.png" alt="Repo setup" width="629" height="255"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Example &lt;code&gt;docker-compose.yml&lt;/code&gt; (Paperless-ngx)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;broker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redisdata:/data&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/sdb1/paperless-new/db:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&lt;/span&gt;

  &lt;span class="na"&gt;webserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/paperless-ngx/paperless-ngx:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;broker&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000:8000"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-fs"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-S"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-time"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/sdb1/paperless-new/data:/usr/src/paperless/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/sdb1/paperless-new/media:/usr/src/paperless/media&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/sdb1/export:/usr/src/paperless/export&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stack.env&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_REDIS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://broker:6379&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_DBHOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redisdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: GitHub Authentication for Portainer
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Create a GitHub Personal Access Token
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;&lt;a href="https://github.com/settings/tokens/new" rel="noopener noreferrer"&gt;GitHub Tokens&lt;/a&gt;&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Name the token (e.g., &lt;code&gt;Portainer-GitOps&lt;/code&gt;) and set expiration.
&lt;/li&gt;
&lt;li&gt;Grant &lt;strong&gt;repo&lt;/strong&gt; permissions (read/write for private repos).
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhlzxk8gg87x0xjivugsi.png" alt="GitHub token permissions" width="764" height="541"&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy the token&lt;/strong&gt;—you’ll need it for Portainer.&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Step 3: Configuring Portainer Stack
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In Portainer, navigate to &lt;strong&gt;Stacks&lt;/strong&gt; &amp;gt; &lt;strong&gt;Add Stack&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Build Method&lt;/strong&gt;, select &lt;strong&gt;Repository&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Enable authentication and input:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Username&lt;/strong&gt;: Your GitHub handle
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password&lt;/strong&gt;: The token from Step 2
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Configure GitOps:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repository URL&lt;/strong&gt;: &lt;code&gt;https://github.com/your-username/repo-name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compose Path&lt;/strong&gt;: &lt;code&gt;docker-compose.yml&lt;/code&gt; (adjust if needed)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable Automatic Updates&lt;/strong&gt;: Toggle &lt;strong&gt;Webhook&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Enable &lt;strong&gt;Re-pull image&lt;/strong&gt; and &lt;strong&gt;Redeploy when changes are pulled&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Add environment variables (e.g., worker counts):
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   PAPERLESS_WEBSERVER_WORKERS=1
   PAPERLESS_TASK_WORKERS=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Deploy the stack!&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Step 4: Cloudflare Tunnel Service Token
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In &lt;strong&gt;&lt;a href="https://one.dash.cloudflare.com" rel="noopener noreferrer"&gt;Cloudflare Zero Trust&lt;/a&gt;&lt;/strong&gt;, go to &lt;strong&gt;Access&lt;/strong&gt; &amp;gt; &lt;strong&gt;Service Auth&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Create a new &lt;strong&gt;Service Token&lt;/strong&gt;. Note the &lt;strong&gt;Client ID&lt;/strong&gt; and &lt;strong&gt;Secret&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Edit your Portainer application under &lt;strong&gt;Applications&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;Add a &lt;strong&gt;Bypass&lt;/strong&gt; policy tied to the service token.
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb4vadpwxa2mcljzbuasu.png" alt="Cloudflare policy setup" width="631" height="230"&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Step 5: GitHub Actions Workflow
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Configure Secrets in GitHub
&lt;/h3&gt;

&lt;p&gt;Under repo &lt;strong&gt;Settings&lt;/strong&gt; &amp;gt; &lt;strong&gt;Secrets&lt;/strong&gt; &amp;gt; &lt;strong&gt;Actions&lt;/strong&gt;, add:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PORTAINER_WEBHOOK_URL&lt;/code&gt;: From Portainer’s webhook setup
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CF_ACCESS_CLIENT_ID&lt;/code&gt;: Cloudflare Service Token Client ID
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CF_ACCESS_CLIENT_SECRET&lt;/code&gt;: Cloudflare Service Token Secret
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Create the Workflow File
&lt;/h3&gt;

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

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

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Manual trigger&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;update-stack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Only run if commit message contains [deploy]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contains(github.event.head_commit.message, '[deploy]')&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger Portainer Webhook&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;PORTAINER_WEBHOOK_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PORTAINER_WEBHOOK_URL }}&lt;/span&gt;
          &lt;span class="na"&gt;CF_ACCESS_CLIENT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CF_ACCESS_CLIENT_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;CF_ACCESS_CLIENT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CF_ACCESS_CLIENT_SECRET }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -X POST "$PORTAINER_WEBHOOK_URL" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 6: Testing the Pipeline
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Commit changes with &lt;code&gt;[deploy]&lt;/code&gt; in the message:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore: update compose [deploy]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Push to trigger the action:
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fodbxohgue7tzuvsl13kg.png" alt="GitHub Actions success" width="800" height="84"&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Portainer will now redeploy your stack automatically! Failed deployments roll back gracefully, and you can reuse the repo as a template for future projects.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;This setup brings GitOps practices to self-hosting—version control, CI/CD, and secure access. Suggestions? Let me know! 🚀&lt;/p&gt;




</description>
    </item>
  </channel>
</rss>
