<?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: FOLASAYO SAMUEL OLAYEMI</title>
    <description>The latest articles on DEV Community by FOLASAYO SAMUEL OLAYEMI (@saint_vandora).</description>
    <link>https://dev.to/saint_vandora</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%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg</url>
      <title>DEV Community: FOLASAYO SAMUEL OLAYEMI</title>
      <link>https://dev.to/saint_vandora</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saint_vandora"/>
    <language>en</language>
    <item>
      <title>How to Give a Linux User Scoped Access to a Specific Docker Container</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Wed, 29 Jul 2026 14:27:10 +0000</pubDate>
      <link>https://dev.to/saint_vandora/when-you-run-multiple-services-on-a-shared-linux-server-one-of-the-most-common-devops-challenges-3obc</link>
      <guid>https://dev.to/saint_vandora/when-you-run-multiple-services-on-a-shared-linux-server-one-of-the-most-common-devops-challenges-3obc</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck" class="crayons-story__hidden-navigation-link"&gt;How to Give a Linux User Scoped Access to a Specific Docker Container (Without Exposing the Entire Server)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/saint_vandora" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" alt="saint_vandora profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/saint_vandora" class="crayons-story__secondary fw-medium m:hidden"&gt;
              FOLASAYO SAMUEL OLAYEMI
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                FOLASAYO SAMUEL OLAYEMI
                
              
              &lt;div id="story-author-preview-content-4263733" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/saint_vandora" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;FOLASAYO SAMUEL OLAYEMI&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 29&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck" id="article-link-4263733"&gt;
          How to Give a Linux User Scoped Access to a Specific Docker Container (Without Exposing the Entire Server)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/linux"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;linux&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            7 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>devops</category>
      <category>docker</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Give a Linux User Scoped Access to a Specific Docker Container (Without Exposing the Entire Server)</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Wed, 29 Jul 2026 14:26:48 +0000</pubDate>
      <link>https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck</link>
      <guid>https://dev.to/saint_vandora/how-to-give-a-linux-user-scoped-access-to-a-specific-docker-container-without-exposing-the-entire-10ck</guid>
      <description>&lt;p&gt;When you run multiple services on a shared Linux server, one of the most common DevOps challenges is giving a teammate or a client access to exactly one running service: nothing more, nothing less. You don't want to hand them root access, you don't want them browsing other application directories, and you don't want them accidentally taking down unrelated containers.&lt;/p&gt;

&lt;p&gt;This article walks through a practical, real-world approach to scoped Docker container access on a shared Linux server. By the end, you will have a second Linux user who logs in and lands directly inside a specific Docker container, with visibility into the app's configuration files and real-time logs: and no access to anything else on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We have a Linux server running several Docker Compose stacks. One of those stacks is a payment service. A developer needs access to that payment service: they need to inspect the running container, read the environment configuration, and tail logs in real time. They should not have access to other stacks or host-level system files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we will do:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the user exists on the server&lt;/li&gt;
&lt;li&gt;Add them to the Docker group&lt;/li&gt;
&lt;li&gt;Create a restricted login shell that drops them into the target container&lt;/li&gt;
&lt;li&gt;Set the correct directory permissions so they can read the app config files&lt;/li&gt;
&lt;li&gt;Verify everything works end to end&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: Check If the User Already Exists
&lt;/h2&gt;

&lt;p&gt;Before creating a new user, check whether one has already been provisioned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'myapp-dev'&lt;/span&gt; /etc/passwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; searches for a pattern in a file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-i&lt;/code&gt; makes the search case-insensitive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-E&lt;/code&gt; enables extended regular expressions (useful if you want to match patterns like &lt;code&gt;myapp|otherapp&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/etc/passwd&lt;/code&gt; is the file Linux uses to store user account information: every user on the system has a line here&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;myapp&lt;/span&gt;-&lt;span class="n"&gt;dev&lt;/span&gt;:&lt;span class="n"&gt;x&lt;/span&gt;:&lt;span class="m"&gt;1002&lt;/span&gt;:&lt;span class="m"&gt;1002&lt;/span&gt;:,,,:/&lt;span class="n"&gt;home&lt;/span&gt;/&lt;span class="n"&gt;myapp&lt;/span&gt;-&lt;span class="n"&gt;dev&lt;/span&gt;:/&lt;span class="n"&gt;bin&lt;/span&gt;/&lt;span class="n"&gt;bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each field separated by &lt;code&gt;:&lt;/code&gt; means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;myapp-dev&lt;/code&gt;: username&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;x&lt;/code&gt;: password is stored in &lt;code&gt;/etc/shadow&lt;/code&gt; (hashed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1002&lt;/code&gt;: user ID (UID)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1002&lt;/code&gt;: primary group ID (GID)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;,,,&lt;/code&gt;: optional comment/GECOS field (name, phone, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/home/myapp-dev&lt;/code&gt;: home directory&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/bin/bash&lt;/code&gt;: login shell&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the user does not exist, create them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash myapp-dev
passwd myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;useradd&lt;/code&gt;: creates a new user account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-m&lt;/code&gt;: creates a home directory at &lt;code&gt;/home/myapp-dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-s /bin/bash&lt;/code&gt;: sets bash as the default shell&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;passwd myapp-dev&lt;/code&gt;: sets a password for the user interactively&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Check the User's Current Groups
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id &lt;/span&gt;myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;id&lt;/code&gt; prints the user ID, primary group ID, and all supplementary groups a user belongs to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;uid=1002(myapp-dev) gid=1002(myapp-dev) groups=1002(myapp-dev),1003(myapp)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells you the user exists but is not yet in the &lt;code&gt;docker&lt;/code&gt; group, meaning they cannot interact with Docker at all yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Add the User to the Docker Group
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;usermod&lt;/code&gt;: modifies an existing user account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-a&lt;/code&gt;: append (do not replace existing groups)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-G docker&lt;/code&gt;: add the user to the &lt;code&gt;docker&lt;/code&gt; supplementary group&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; Docker's socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;) is only accessible to root and members of the &lt;code&gt;docker&lt;/code&gt; group. Without this step, the user cannot run any &lt;code&gt;docker&lt;/code&gt; commands at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; Adding a user to the &lt;code&gt;docker&lt;/code&gt; group effectively gives them root-equivalent access to the host via Docker (e.g. they could mount the host filesystem). For a trusted developer in a dev environment this is acceptable. In production, consider rootless Docker or more granular controls.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Verify the group was added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id &lt;/span&gt;myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should now see &lt;code&gt;docker&lt;/code&gt; in their groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;uid=1002(myapp-dev) gid=1002(myapp-dev) groups=1002(myapp-dev),998(docker),1003(myapp)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Create a Restricted Login Shell
&lt;/h2&gt;

&lt;p&gt;Instead of giving the user a full bash session on the host, we create a small shell script that immediately drops them into the target container when they log in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /usr/local/bin/myapp-shell &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
exec docker exec -it my-payment-container /bin/sh
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cat &amp;gt;&lt;/code&gt;: writes the following content into a file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;&amp;lt; 'EOF' ... EOF&lt;/code&gt;: a heredoc block; everything between the two &lt;code&gt;EOF&lt;/code&gt; markers is written as-is to the file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#!/bin/bash&lt;/code&gt;: shebang line; tells the OS to run this script with bash&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exec&lt;/code&gt;: replaces the current shell process with the docker exec command (no parent shell remains)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec&lt;/code&gt;: runs a command inside an already-running container&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-i&lt;/code&gt;: keeps STDIN open (interactive mode)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-t&lt;/code&gt;: allocates a pseudo-TTY (gives you a proper terminal experience)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;my-payment-container&lt;/code&gt;: the name of your target container (get this from &lt;code&gt;docker ps&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/bin/sh&lt;/code&gt;: the shell to open inside the container (use &lt;code&gt;/bin/bash&lt;/code&gt; if the container image has bash installed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make the script executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/myapp-shell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;chmod&lt;/code&gt;: changes file permissions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+x&lt;/code&gt;: adds the execute bit, making it runnable as a program&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Alternative shells you could use:&lt;/strong&gt; If the container has bash, replace &lt;code&gt;/bin/sh&lt;/code&gt; with &lt;code&gt;/bin/bash&lt;/code&gt; for a better experience. Some minimal images (Alpine-based) only have &lt;code&gt;/bin/sh&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5: Set the Restricted Shell as the User's Login Shell
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usermod &lt;span class="nt"&gt;-s&lt;/span&gt; /usr/local/bin/myapp-shell myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;usermod&lt;/code&gt;: modifies the user account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-s&lt;/code&gt;: sets the login shell&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/usr/local/bin/myapp-shell&lt;/code&gt;: the script we just created&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now whenever &lt;code&gt;myapp-dev&lt;/code&gt; logs in via SSH or &lt;code&gt;su&lt;/code&gt;, instead of getting a bash prompt on the host, they are immediately placed inside the container.&lt;/p&gt;

&lt;p&gt;Verify the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;myapp-dev /etc/passwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last field should now show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;myapp&lt;/span&gt;-&lt;span class="n"&gt;dev&lt;/span&gt;:&lt;span class="n"&gt;x&lt;/span&gt;:&lt;span class="m"&gt;1002&lt;/span&gt;:&lt;span class="m"&gt;1002&lt;/span&gt;:,,,:/&lt;span class="n"&gt;home&lt;/span&gt;/&lt;span class="n"&gt;myapp&lt;/span&gt;-&lt;span class="n"&gt;dev&lt;/span&gt;:/&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;local&lt;/span&gt;/&lt;span class="n"&gt;bin&lt;/span&gt;/&lt;span class="n"&gt;myapp&lt;/span&gt;-&lt;span class="n"&gt;shell&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Give the User Visibility Into the App Directory
&lt;/h2&gt;

&lt;p&gt;By default the user's home directory is &lt;code&gt;/home/myapp-dev&lt;/code&gt;. The actual application files (&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;docker-compose.yml&lt;/code&gt;, logs) live in a different directory, for example &lt;code&gt;/home/apps/my-payment-service&lt;/code&gt;. We need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give the user's group read access to that directory&lt;/li&gt;
&lt;li&gt;Change their home directory so they land there on login&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Set group ownership and permissions:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chown &lt;/span&gt;root:myapp /home/apps/my-payment-service
&lt;span class="nb"&gt;chmod &lt;/span&gt;750 /home/apps/my-payment-service
&lt;span class="nb"&gt;chmod &lt;/span&gt;g+r /home/apps/my-payment-service/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;chown&lt;/code&gt;: changes file/directory ownership&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;root:myapp&lt;/code&gt;: sets owner to root, group to &lt;code&gt;myapp&lt;/code&gt; (the group &lt;code&gt;myapp-dev&lt;/code&gt; already belongs to)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chmod 750&lt;/code&gt;: owner gets read/write/execute, group gets read/execute, others get nothing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;g+r&lt;/code&gt;: adds read permission for the group on the &lt;code&gt;.env&lt;/code&gt; file specifically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Change the user's home directory:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the user has an active session, kill it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkill &lt;span class="nt"&gt;-u&lt;/span&gt; myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pkill&lt;/code&gt;: sends a signal to processes matching a criterion&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-u myapp-dev&lt;/code&gt;: matches all processes owned by &lt;code&gt;myapp-dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;This sends &lt;code&gt;SIGTERM&lt;/code&gt; by default, gracefully terminating their session&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then update the home directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usermod &lt;span class="nt"&gt;-d&lt;/span&gt; /home/apps/my-payment-service myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-d&lt;/code&gt;: sets the user's home directory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now when &lt;code&gt;myapp-dev&lt;/code&gt; logs in they land directly at &lt;code&gt;/home/apps/my-payment-service&lt;/code&gt; and can see &lt;code&gt;docker-compose.yml&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt;, and any log directories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Verify End to End
&lt;/h2&gt;

&lt;p&gt;Switch to the user and test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;su - myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;su&lt;/code&gt;: substitute user (switch to another user)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-&lt;/code&gt;: loads the full login environment for that user (home directory, shell, environment variables)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If the login shell is set to the container script&lt;/strong&gt;, you will land directly inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/ &lt;span class="err"&gt;$&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If the login shell is set to bash&lt;/strong&gt;, you will land in the app directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;myapp-dev@server:/home/apps/my-payment-service$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="go"&gt;.  ..  .env  docker-compose.yml  live-logs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, the user can also interact with Docker normally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# See running containers in this stack&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml ps

&lt;span class="c"&gt;# Tail real-time logs&lt;/span&gt;
docker logs &lt;span class="nt"&gt;-f&lt;/span&gt; my-payment-container

&lt;span class="c"&gt;# Shell into the container manually&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; my-payment-container /bin/sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Both Users Share
&lt;/h2&gt;

&lt;p&gt;Once this is set up, both root and &lt;code&gt;myapp-dev&lt;/code&gt; are operating on the &lt;strong&gt;same running containers&lt;/strong&gt;. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker compose ps&lt;/code&gt;: both see identical container states&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker logs -f my-payment-container&lt;/code&gt;: both stream the same real-time log output simultaneously&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec -it my-payment-container /bin/sh&lt;/code&gt;: both can open shells inside the container at the same time&lt;/li&gt;
&lt;li&gt;Any restart or config change made by either user affects the same running service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the intended behaviour for a shared dev environment: one running stack, multiple people with visibility into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the Two Access Modes
&lt;/h2&gt;

&lt;p&gt;Depending on how you configure the login shell, you get two different access patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Login Shell&lt;/th&gt;
&lt;th&gt;Where They Land&lt;/th&gt;
&lt;th&gt;What They See&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container-only&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/local/bin/myapp-shell&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inside the container filesystem&lt;/td&gt;
&lt;td&gt;Container files only, no host files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Host directory scoped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/bin/bash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;App directory on the host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;docker-compose.yml&lt;/code&gt;, logs, and Docker CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a developer who needs to read config, tail logs, and inspect the compose setup: the &lt;strong&gt;host directory scoped&lt;/strong&gt; mode is more practical. For a user who only needs to run commands inside the application process: the &lt;strong&gt;container-only&lt;/strong&gt; mode is more restrictive and appropriate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Reference: All Commands
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check if user exists&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'myapp-dev'&lt;/span&gt; /etc/passwd

&lt;span class="c"&gt;# Create user if needed&lt;/span&gt;
useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash myapp-dev
passwd myapp-dev

&lt;span class="c"&gt;# Check current groups&lt;/span&gt;
&lt;span class="nb"&gt;id &lt;/span&gt;myapp-dev

&lt;span class="c"&gt;# Add to docker group&lt;/span&gt;
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker myapp-dev

&lt;span class="c"&gt;# Create restricted container shell&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /usr/local/bin/myapp-shell &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
exec docker exec -it my-payment-container /bin/sh
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/myapp-shell

&lt;span class="c"&gt;# Set as login shell&lt;/span&gt;
usermod &lt;span class="nt"&gt;-s&lt;/span&gt; /usr/local/bin/myapp-shell myapp-dev

&lt;span class="c"&gt;# Or set bash as login shell (host access mode)&lt;/span&gt;
usermod &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash myapp-dev

&lt;span class="c"&gt;# Set app directory permissions&lt;/span&gt;
&lt;span class="nb"&gt;chown &lt;/span&gt;root:myapp /home/apps/my-payment-service
&lt;span class="nb"&gt;chmod &lt;/span&gt;750 /home/apps/my-payment-service

&lt;span class="c"&gt;# Kill active session before modifying home dir&lt;/span&gt;
pkill &lt;span class="nt"&gt;-u&lt;/span&gt; myapp-dev

&lt;span class="c"&gt;# Change home directory&lt;/span&gt;
usermod &lt;span class="nt"&gt;-d&lt;/span&gt; /home/apps/my-payment-service myapp-dev

&lt;span class="c"&gt;# Test login&lt;/span&gt;
su - myapp-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;With a few targeted &lt;code&gt;usermod&lt;/code&gt; commands, a wrapper shell script, and careful directory permissions, you can give a developer or client scoped access to a specific Docker service running on a shared server: without handing them the keys to everything else.&lt;/p&gt;

&lt;p&gt;This pattern is particularly useful in multi-tenant dev environments where multiple application stacks share a single droplet or VM and you need to delegate access per service without spinning up separate servers or managing complex ACLs.&lt;/p&gt;

&lt;p&gt;For production environments, consider extending this pattern with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rootless Docker&lt;/strong&gt;: each user runs their own Docker daemon with no shared socket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sudoers rules&lt;/strong&gt;: restrict which Docker commands a user can run via &lt;code&gt;sudo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit logging&lt;/strong&gt;: use &lt;code&gt;auditd&lt;/code&gt; to log all Docker exec sessions for the scoped user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading...&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>devops</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Branch-Based CI/CD: Quality Gate to Multi-Environment Deploy with GitHub Actions</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:06:16 +0000</pubDate>
      <link>https://dev.to/saint_vandora/branch-based-cicd-quality-gate-to-multi-environment-deploy-with-github-actions-16jf</link>
      <guid>https://dev.to/saint_vandora/branch-based-cicd-quality-gate-to-multi-environment-deploy-with-github-actions-16jf</guid>
      <description>&lt;p&gt;A complete, reusable pattern for wiring a code-quality gate (SonarQube) to Docker&lt;br&gt;
build, image scanning, and SSH-based deployment across development, staging, and&lt;br&gt;
production  using a single reusable workflow and GitHub Environments for secrets&lt;br&gt;
and approval gates.&lt;/p&gt;

&lt;p&gt;This document explains not just &lt;em&gt;what&lt;/em&gt; to copy, but &lt;em&gt;why&lt;/em&gt; each piece is shaped the&lt;br&gt;
way it is, including the failure modes that quietly break this kind of pipeline.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. The problem this solves
&lt;/h2&gt;

&lt;p&gt;A common goal: when code lands on a branch and passes the quality gate, automatically&lt;br&gt;
build a Docker image, scan it for vulnerabilities, and deploy it to the server that&lt;br&gt;
matches that branch &lt;code&gt;develop&lt;/code&gt; to dev, &lt;code&gt;staging&lt;/code&gt; to staging, &lt;code&gt;main&lt;/code&gt; to production, with production requiring a human to approve the release.&lt;/p&gt;

&lt;p&gt;The naive approach is one deploy file per environment, each triggered independently.&lt;br&gt;
That works but rots fast: three files drift apart, a fix in one is forgotten in the&lt;br&gt;
others, and secrets get duplicated. The pattern below uses &lt;strong&gt;one&lt;/strong&gt; reusable deploy&lt;br&gt;
workflow parameterized by environment, called from the quality-gate workflow, with&lt;br&gt;
per-environment secrets and protection rules living in GitHub Environments.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. The &lt;code&gt;workflow_run&lt;/code&gt; trap (why the obvious approach fails silently)
&lt;/h2&gt;

&lt;p&gt;The instinctive way to chain "run B after A finishes" is the &lt;code&gt;workflow_run&lt;/code&gt; trigger:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&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;CodeQuality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Checks"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;develop&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks correct and frequently does nothing at all. Two rules cause most of the&lt;br&gt;
silent failures:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: &lt;code&gt;workflow_run&lt;/code&gt; is armed only from the default branch.&lt;/strong&gt; GitHub reads a&lt;br&gt;
&lt;code&gt;workflow_run&lt;/code&gt; trigger definition &lt;em&gt;only&lt;/em&gt; from the copy of the file on the repository's&lt;br&gt;
default branch. If your default branch is, say, &lt;code&gt;main&lt;/code&gt; (or something unexpected like a&lt;br&gt;
&lt;code&gt;codex/*&lt;/code&gt; branch) and the deploy file exists only on &lt;code&gt;develop&lt;/code&gt;, the trigger is never&lt;br&gt;
registered. The upstream workflow can pass a thousand times; nothing listens. The&lt;br&gt;
&lt;code&gt;branches:&lt;/code&gt; filter does not change this — the file must physically exist on the default&lt;br&gt;
branch to be active, even though the filter then restricts it to acting on &lt;code&gt;develop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A quick way to discover your real default branch is the "This branch is N commits ahead&lt;br&gt;
of X" banner on the repo's Code tab — &lt;code&gt;X&lt;/code&gt; is the default branch GitHub compares against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: the &lt;code&gt;branches:&lt;/code&gt; filter matches the &lt;em&gt;upstream run's head branch&lt;/em&gt;.&lt;/strong&gt; It does&lt;br&gt;
not mean "the target branch." If the upstream (quality) workflow runs on&lt;br&gt;
&lt;code&gt;pull_request&lt;/code&gt;, its head branch is the PR's source branch (e.g. &lt;code&gt;feature/x&lt;/code&gt;), not&lt;br&gt;
&lt;code&gt;develop&lt;/code&gt;, so &lt;code&gt;branches: [develop]&lt;/code&gt; never matches. It only lines up when the upstream&lt;br&gt;
runs on &lt;code&gt;push&lt;/code&gt; to &lt;code&gt;develop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because of these two traps and because relying on the default branch is undesirable&lt;br&gt;
when your default branch is not the branch you deploy from — this document avoids&lt;br&gt;
&lt;code&gt;workflow_run&lt;/code&gt; entirely and uses a &lt;strong&gt;reusable workflow&lt;/strong&gt; instead.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Why a reusable workflow (&lt;code&gt;workflow_call&lt;/code&gt;) is the right primitive
&lt;/h2&gt;

&lt;p&gt;A workflow invoked with &lt;code&gt;workflow_call&lt;/code&gt; is resolved from the &lt;strong&gt;caller's ref&lt;/strong&gt;, not the&lt;br&gt;
default branch. If the quality workflow runs on &lt;code&gt;develop&lt;/code&gt; and calls the deploy workflow&lt;br&gt;
with &lt;code&gt;uses: ./.github/workflows/deploy.yml&lt;/code&gt;, GitHub loads the deploy file &lt;em&gt;from&lt;br&gt;
&lt;code&gt;develop&lt;/code&gt;&lt;/em&gt;. No default-branch dependency, no head-branch filter guessing. The two&lt;br&gt;
workflows also appear as a single run graph with a real dependency edge, so a failed&lt;br&gt;
build visibly blocks the deploy.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt; (manual/API trigger) is &lt;em&gt;not&lt;/em&gt; an escape hatch here: dispatching via&lt;br&gt;
the API has the same default-branch requirement as &lt;code&gt;workflow_run&lt;/code&gt;. Reusable&lt;br&gt;
&lt;code&gt;workflow_call&lt;/code&gt; is the only model fully independent of the default branch.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Architecture overview
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;push to develop / staging / main
        │
        ▼
┌─────────────────────────┐
│  Quality workflow        │   runs on: push [develop, staging, main]
│  (SonarQube scan + gate) │
└───────────┬─────────────┘
            │ success()
            ▼
┌─────────────────────────┐
│  resolve-env job         │   maps branch → environment / tag / path
└───────────┬─────────────┘
            │ outputs
            ▼
┌─────────────────────────────────────────────┐
│  deploy.yml (reusable, workflow_call)         │
│                                               │
│  dependency-check → build-and-push →          │
│  image-scan → deploy (Environment-gated)      │
└───────────────────────────────────────────────┘
            │
            ▼
   dev / staging / production server (SSH + docker compose)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Branch-to-environment mapping:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Image tag&lt;/th&gt;
&lt;th&gt;Deploy path&lt;/th&gt;
&lt;th&gt;Approval&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;develop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/apps/pmis-web-dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;staging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;staging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;staging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/apps/pmis-web-staging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/apps/pmis-web-prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  5. Prerequisites: GitHub Environments
&lt;/h2&gt;

&lt;p&gt;Before the workflows will work, create three Environments under&lt;br&gt;
&lt;strong&gt;Settings → Environments&lt;/strong&gt;: &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Environments do two jobs here. First, they scope secrets: each environment holds its&lt;br&gt;
own copy of the deploy secrets pointing at &lt;em&gt;that&lt;/em&gt; environment's server. Second, they&lt;br&gt;
enforce protection rules: adding &lt;strong&gt;required reviewers&lt;/strong&gt; to &lt;code&gt;production&lt;/code&gt; makes the deploy&lt;br&gt;
job pause and wait for a human to approve before it runs.&lt;/p&gt;

&lt;p&gt;Inside each environment, define these secrets (same key names, different values per&lt;br&gt;
environment):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_IP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Target host for that environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSH user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_SSH_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Private key for that user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GHCR_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Token with &lt;code&gt;read:packages&lt;/code&gt; for pulling on server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GHCR_USERNAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GHCR username matching the token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public API URL baked into the build for that env&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Repository-level (or organization-level) secrets, shared across environments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SONAR_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SonarQube auth token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SONAR_HOST_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SonarQube server URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is provided automatically; no need to create it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: the key names here are env-neutral (&lt;code&gt;SERVER_IP&lt;/code&gt; rather than &lt;code&gt;DEV_SERVER_IP&lt;/code&gt;).&lt;br&gt;
Because each environment supplies its own value, there is no reason to prefix them&lt;br&gt;
with &lt;code&gt;DEV_&lt;/code&gt;. Pick one convention and use it consistently in both the workflow files&lt;br&gt;
and every environment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  6. The quality-gate workflow (the caller)
&lt;/h2&gt;

&lt;p&gt;This runs on every push to the three deployable branches, runs the SonarQube scan and&lt;br&gt;
quality gate, then only if the gate passed and the push was to a known branch, resolves the target environment and calls the reusable deploy workflow.&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;CodeQuality Checks&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="s"&gt;develop&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 1. SonarQube scan + quality gate&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;code-quality&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;Code Quality&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;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;            &lt;span class="c1"&gt;# full history so Sonar can attribute blame&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;SonarQube Scan&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonarsource/sonarqube-scan-action@master&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;SONAR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;SONAR_HOST_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_HOST_URL }}&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;SonarQube Quality Gate&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonarsource/sonarqube-quality-gate-action@master&lt;/span&gt;
        &lt;span class="na"&gt;timeout-minutes&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;SONAR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_TOKEN }}&lt;/span&gt;

  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 2. Map the pushed branch to an environment&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;resolve-env&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;Resolve Target Environment&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;code-quality&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;${{ success() &amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="s"&gt;contains(fromJSON('["refs/heads/develop","refs/heads/staging","refs/heads/main"]'),&lt;/span&gt;
                   &lt;span class="s"&gt;github.ref) }}&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;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.map.outputs.environment }}&lt;/span&gt;
      &lt;span class="na"&gt;image_tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;${{ steps.map.outputs.image_tag }}&lt;/span&gt;
      &lt;span class="na"&gt;deploy_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.map.outputs.deploy_path }}&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;Map branch to environment&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;map&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;case "${{ github.ref }}" in&lt;/span&gt;
            &lt;span class="s"&gt;refs/heads/develop)&lt;/span&gt;
              &lt;span class="s"&gt;echo "environment=dev"                       &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;echo "image_tag=dev"                          &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;echo "deploy_path=/home/apps/pmis-web-dev"     &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;;;&lt;/span&gt;
            &lt;span class="s"&gt;refs/heads/staging)&lt;/span&gt;
              &lt;span class="s"&gt;echo "environment=staging"                   &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;echo "image_tag=staging"                      &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;echo "deploy_path=/home/apps/pmis-web-staging" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;;;&lt;/span&gt;
            &lt;span class="s"&gt;refs/heads/main)&lt;/span&gt;
              &lt;span class="s"&gt;echo "environment=production"                &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;echo "image_tag=prod"                         &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;echo "deploy_path=/home/apps/pmis-web-prod"    &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
              &lt;span class="s"&gt;;;&lt;/span&gt;
          &lt;span class="s"&gt;esac&lt;/span&gt;

  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 3. Call the reusable deploy workflow&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;resolve-env&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/deploy.yml&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.resolve-env.outputs.environment }}&lt;/span&gt;
      &lt;span class="na"&gt;image_tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;${{ needs.resolve-env.outputs.image_tag }}&lt;/span&gt;
      &lt;span class="na"&gt;deploy_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.resolve-env.outputs.deploy_path }}&lt;/span&gt;
      &lt;span class="na"&gt;head_sha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;${{ github.sha }}&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on the caller:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;success()&lt;/code&gt; in the &lt;code&gt;resolve-env&lt;/code&gt; guard already means every job in &lt;code&gt;needs&lt;/code&gt; passed, so a&lt;br&gt;
separate "conclusion == success" check is redundant. The &lt;code&gt;contains(fromJSON(...))&lt;/code&gt;&lt;br&gt;
check ensures the pipeline only fires on the three intended branches and skips cleanly&lt;br&gt;
on anything else. &lt;code&gt;secrets: inherit&lt;/code&gt; forwards all repository &lt;em&gt;and&lt;/em&gt; the resolved&lt;br&gt;
environment's secrets into the called workflow — without it the reusable workflow sees&lt;br&gt;
no secrets at all.&lt;/p&gt;

&lt;p&gt;Passing &lt;code&gt;head_sha: ${{ github.sha }}&lt;/code&gt; and having the reusable workflow check that exact&lt;br&gt;
commit out guarantees you build precisely what Sonar validated, not whatever happens to&lt;br&gt;
be at the branch tip when the deploy job starts.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. The reusable deploy workflow (the engine)
&lt;/h2&gt;

&lt;p&gt;Four jobs run in sequence: audit dependencies, build and push the image, scan the&lt;br&gt;
pushed image, then deploy over SSH. The deploy job is bound to the resolved&lt;br&gt;
Environment, which is what activates per-environment secrets and the production&lt;br&gt;
approval gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy PMIS Web&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;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&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;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Target&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;environment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(dev&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;production)"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;image_tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Image&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tag&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;push,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;deploy_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Absolute&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;compose&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;server"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;head_sha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Commit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SHA&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;audit_level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;audit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;failure&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;threshold"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&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;REGISTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-pmis-web-${{ inputs.environment }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 1. Dependency vulnerability check&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;dependency-check&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;Dependency Vulnerability Check&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&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;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.head_sha }}&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;Set up Node.js&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&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;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&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;Audit dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm audit --audit-level=${{ inputs.audit_level }}&lt;/span&gt;

  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 2. Build the image in CI and push to GHCR&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;build-and-push&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;Build &amp;amp; Push Image&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dependency-check&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;outputs&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;${{ steps.image-name.outputs.image }}&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;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.head_sha }}&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;Set lowercase image name&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-name&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;echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" &amp;gt;&amp;gt; $GITHUB_OUTPUT&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;Set up Docker Buildx&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v4&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;Log in to GitHub Container Registry&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&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;Extract Docker image metadata&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/metadata-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.image-name.outputs.image }}&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;type=sha,prefix=sha-&lt;/span&gt;
            &lt;span class="s"&gt;type=raw,value=${{ inputs.image_tag }}&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;Build and push Docker image&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./Dockerfile&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.tags }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.labels }}&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
          &lt;span class="na"&gt;build-args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}&lt;/span&gt;

  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 3. Scan the pushed image for OS / package CVEs&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;image-scan&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;Container Security Scan&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-push&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;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;Log in to GitHub Container Registry&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&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;Scan image with Trivy&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25&lt;/span&gt; &lt;span class="c1"&gt;# v0.36.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build-and-push.outputs.image }}:${{ inputs.image_tag }}&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CRITICAL,HIGH&lt;/span&gt;
          &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
          &lt;span class="na"&gt;ignore-unfixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;# 4. Pull the pre-built image on the server and restart&lt;/span&gt;
  &lt;span class="c1"&gt;# ─────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Server&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;image-scan&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.environment }}&lt;/span&gt;   &lt;span class="c1"&gt;# activates env secrets + approval gate&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Server via SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&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;GHCR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;${{ secrets.GHCR_TOKEN }}&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;${{ needs.build-and-push.outputs.image }}&lt;/span&gt;
          &lt;span class="na"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;${{ inputs.image_tag }}&lt;/span&gt;
          &lt;span class="na"&gt;DEPLOY_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.deploy_path }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;${{ secrets.SERVER_IP }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;envs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GHCR_TOKEN,IMAGE,IMAGE_TAG,DEPLOY_PATH&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin&lt;/span&gt;
            &lt;span class="s"&gt;docker pull "$IMAGE:$IMAGE_TAG"&lt;/span&gt;
            &lt;span class="s"&gt;cd "$DEPLOY_PATH"&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f docker-compose.yml down --remove-orphans&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f docker-compose.yml up -d&lt;/span&gt;
            &lt;span class="s"&gt;docker image prune -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  8. Design decisions worth understanding
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build once, deploy the artifact.&lt;/strong&gt; The image is built and pushed in CI, and the&lt;br&gt;
server merely pulls a pre-built tag. The server never runs &lt;code&gt;docker build&lt;/code&gt;, so a deploy&lt;br&gt;
is fast and reproducible, and a broken build can never leave a half-built container on&lt;br&gt;
the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan before deploy, not after.&lt;/strong&gt; The Trivy step sits between build and deploy with&lt;br&gt;
&lt;code&gt;exit-code: '1'&lt;/code&gt; on CRITICAL/HIGH findings, so a vulnerable image is blocked from ever&lt;br&gt;
reaching a server. &lt;code&gt;ignore-unfixed: true&lt;/code&gt; prevents the gate from failing on CVEs that&lt;br&gt;
have no available fix — tune this to your risk tolerance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The image tag must actually exist.&lt;/strong&gt; A subtle, common bug: &lt;code&gt;metadata-action&lt;/code&gt; here&lt;br&gt;
emits a &lt;code&gt;sha-&amp;lt;sha&amp;gt;&lt;/code&gt; tag and the environment tag (&lt;code&gt;dev&lt;/code&gt;/&lt;code&gt;staging&lt;/code&gt;/&lt;code&gt;prod&lt;/code&gt;). If a later&lt;br&gt;
step references a tag that was never produced — for example hardcoding &lt;code&gt;:dev&lt;/code&gt; while the&lt;br&gt;
metadata block only outputs &lt;code&gt;latest&lt;/code&gt; — the scan and the server pull both fail on a&lt;br&gt;
missing tag. This workflow avoids that by driving both the metadata &lt;code&gt;type=raw&lt;/code&gt; tag and&lt;br&gt;
the pull/scan references from the same &lt;code&gt;inputs.image_tag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build args must match the Dockerfile.&lt;/strong&gt; The build passes&lt;br&gt;
&lt;code&gt;NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}&lt;/code&gt;. This only takes effect if the&lt;br&gt;
Dockerfile declares a matching &lt;code&gt;ARG NEXT_PUBLIC_API_URL&lt;/code&gt;. A mismatch between the arg&lt;br&gt;
name here and the &lt;code&gt;ARG&lt;/code&gt; in the Dockerfile is silent — the value simply never reaches&lt;br&gt;
the build. Verify the names line up. (This is a frequent source of "the env var is&lt;br&gt;
empty in the built app" confusion.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrency is per-environment.&lt;/strong&gt; &lt;code&gt;group: deploy-pmis-web-${{ inputs.environment }}&lt;/code&gt;&lt;br&gt;
means a dev deploy and a prod deploy can run at the same time, but two deploys to the&lt;br&gt;
&lt;em&gt;same&lt;/em&gt; environment queue rather than clobber each other. &lt;code&gt;cancel-in-progress: false&lt;/code&gt;&lt;br&gt;
lets an in-flight deploy finish rather than being killed mid-way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quoting in the SSH script.&lt;/strong&gt; Values are passed through &lt;code&gt;envs:&lt;/code&gt; and referenced as&lt;br&gt;
shell variables (&lt;code&gt;$IMAGE&lt;/code&gt;, &lt;code&gt;$DEPLOY_PATH&lt;/code&gt;) with quotes, rather than interpolated&lt;br&gt;
directly into the script body. This avoids word-splitting and injection surprises if a&lt;br&gt;
value ever contains unexpected characters.&lt;/p&gt;
&lt;h2&gt;
  
  
  9. Setup checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create the three Environments (&lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;) and add
required reviewers to &lt;code&gt;production&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the per-environment secrets to each environment and the shared &lt;code&gt;SONAR_*&lt;/code&gt;
secrets at the repository level.&lt;/li&gt;
&lt;li&gt;Confirm the Dockerfile declares every &lt;code&gt;ARG&lt;/code&gt; the build passes
(&lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt;, plus any others you add).&lt;/li&gt;
&lt;li&gt;Ensure the compose project exists at the mapped path on each server
(&lt;code&gt;/home/apps/pmis-web-{dev,staging,prod}&lt;/code&gt;), with a &lt;code&gt;docker-compose.yml&lt;/code&gt; that
references the image and tag being pushed.&lt;/li&gt;
&lt;li&gt;Confirm the deploy user on each server has permission to run &lt;code&gt;docker&lt;/code&gt; and pull from
GHCR (the &lt;code&gt;GHCR_TOKEN&lt;/code&gt; needs &lt;code&gt;read:packages&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Commit both workflow files. The quality workflow triggers on push to any of the
three branches; because the deploy workflow is called via &lt;code&gt;workflow_call&lt;/code&gt;, it does
&lt;strong&gt;not&lt;/strong&gt; need to exist on the default branch.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  10. Adding a manual (on-demand) deploy path
&lt;/h2&gt;

&lt;p&gt;To deploy an arbitrary branch to any environment on demand — useful for hotfixes or&lt;br&gt;
re-deploys — add a &lt;code&gt;workflow_dispatch&lt;/code&gt; trigger to the &lt;strong&gt;caller&lt;/strong&gt; and hand its inputs to&lt;br&gt;
the same reusable workflow. Add alongside the existing &lt;code&gt;on: push:&lt;/code&gt; block:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;develop&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staging&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="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&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;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Environment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;choice&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then branch the resolver on &lt;code&gt;github.event_name&lt;/code&gt;: for a manual run, read&lt;br&gt;
&lt;code&gt;github.event.inputs.environment&lt;/code&gt; and derive the tag and path from it; for a push,&lt;br&gt;
use the branch mapping as before. The reusable &lt;code&gt;deploy.yml&lt;/code&gt; needs no changes — it only&lt;br&gt;
ever sees resolved inputs.&lt;/p&gt;

&lt;p&gt;Remember: dispatching a workflow via the API/UI requires the file to exist on the&lt;br&gt;
default branch. If you want the manual path but cannot touch the default branch, keep&lt;br&gt;
the dispatch trigger on the &lt;em&gt;caller&lt;/em&gt; (the quality workflow) and let it call the reusable&lt;br&gt;
deploy — the caller is what needs to be dispatchable, and the reusable file stays&lt;br&gt;
resolved from the caller's ref.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing runs after the quality gate passes.&lt;/strong&gt;&lt;br&gt;
Check that the quality workflow actually triggers on &lt;code&gt;push&lt;/code&gt; to the branch in question,&lt;br&gt;
and that the resolver's branch guard includes it. If you migrated from &lt;code&gt;workflow_run&lt;/code&gt;,&lt;br&gt;
confirm you removed all &lt;code&gt;github.event.workflow_run.*&lt;/code&gt; references — they are &lt;code&gt;null&lt;/code&gt;&lt;br&gt;
outside a &lt;code&gt;workflow_run&lt;/code&gt; event and will cause guarded jobs to skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deploy job is skipped even though build succeeded.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;github.ref&lt;/code&gt; is &lt;code&gt;refs/heads/&amp;lt;branch&amp;gt;&lt;/code&gt; only on a push event. On a pull request it is a&lt;br&gt;
merge ref, so a &lt;code&gt;github.ref == 'refs/heads/develop'&lt;/code&gt; style guard is false. Deploy on&lt;br&gt;
push, not PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker pull&lt;/code&gt; fails on the server with a missing tag.&lt;/strong&gt;&lt;br&gt;
The tag referenced at deploy time was never produced at build time. Ensure the&lt;br&gt;
&lt;code&gt;type=raw,value=&amp;lt;tag&amp;gt;&lt;/code&gt; in the metadata step matches the tag used in the scan and pull&lt;br&gt;
steps — drive both from a single input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The built app has empty public env vars.&lt;/strong&gt;&lt;br&gt;
The build-arg name does not match the Dockerfile &lt;code&gt;ARG&lt;/code&gt;, so the value never reaches the&lt;br&gt;
build. Align the names exactly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production deploys without waiting for approval.&lt;/strong&gt;&lt;br&gt;
The deploy job is missing &lt;code&gt;environment: production&lt;/code&gt;, or the &lt;code&gt;production&lt;/code&gt; environment has&lt;br&gt;
no required reviewers configured. The pause comes from the Environment protection rule,&lt;br&gt;
not from anything in the YAML beyond binding the job to that environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reusable-workflow deploy sees no secrets.&lt;/strong&gt;&lt;br&gt;
The caller is missing &lt;code&gt;secrets: inherit&lt;/code&gt; (or an explicit &lt;code&gt;secrets:&lt;/code&gt; block). Called&lt;br&gt;
workflows do not inherit secrets automatically.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Ditch GHCR: A Production-Grade Self-Hosted Docker Registry on a Single DigitalOcean Droplet</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:00:12 +0000</pubDate>
      <link>https://dev.to/saint_vandora/ditch-ghcr-a-production-grade-self-hosted-docker-registry-on-a-single-digitalocean-droplet-5bgg</link>
      <guid>https://dev.to/saint_vandora/ditch-ghcr-a-production-grade-self-hosted-docker-registry-on-a-single-digitalocean-droplet-5bgg</guid>
      <description>&lt;p&gt;If you have ever hit the wall on a hosted registry's free private tier a 500 MB cap here, a per-seat charge there and you already run your own servers, there is a question worth asking: why not host the registry yourself?&lt;/p&gt;

&lt;p&gt;The registry software is not the scary part. &lt;code&gt;registry:2&lt;/code&gt; is the CNCF Distribution project, the same reference implementation that sits &lt;em&gt;underneath&lt;/em&gt; most managed registries. Harbor wraps it. Several big managed registries are built on it. It is boring, battle-tested, and idles at well under 50 MB of RAM.&lt;/p&gt;

&lt;p&gt;What separates a hobby setup from a production one is everything &lt;em&gt;around&lt;/em&gt; the binary: durable storage, TLS, authentication on both push and pull, garbage collection, and not leaving a naked port open to the internet. This article walks through a setup that gets all of that right, end to end, on a single droplet, with copy-paste commands you can follow start to finish.&lt;/p&gt;

&lt;p&gt;By the end you will have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A private registry running behind HTTPS on your own subdomain&lt;/li&gt;
&lt;li&gt;Image layers stored in S3-compatible object storage (durable, effectively unbounded) instead of on the droplet's disk&lt;/li&gt;
&lt;li&gt;A GitHub Actions pipeline that audits dependencies, builds, scans for CVEs, pushes, and deploys&lt;/li&gt;
&lt;li&gt;Automatic garbage collection so the storage does not grow forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below uses placeholder names swap in your own domain, bucket, and paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The single most common self-hosted registry mistake is publishing port &lt;code&gt;5000&lt;/code&gt; to &lt;code&gt;0.0.0.0&lt;/code&gt;. That binds to every interface including the public one, so the whole thing sits open on the internet. We avoid that entirely.&lt;/p&gt;

&lt;p&gt;Because most people fronting their containers already run Nginx Proxy Manager (NPM), the clean approach is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The registry container joins the &lt;strong&gt;same Docker network&lt;/strong&gt; as NPM and is reachable only by its container name no public host port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NPM handles TLS and proxying.&lt;/strong&gt; It requests the Let's Encrypt certificate and terminates HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The registry handles its own authentication&lt;/strong&gt; via an &lt;code&gt;htpasswd&lt;/code&gt; file. This is deliberate: if both NPM (via an Access List) and the registry tried to own the &lt;code&gt;Authorization&lt;/code&gt; header, you would get duplicate-header conflicts. Letting the registry own auth keeps that clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object storage is the backend.&lt;/strong&gt; Local disk on one droplet is a single point of failure and fills up. Pointing the registry at an S3-compatible bucket gives durability and room to grow without touching the disk.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[GitHub Actions]  --push over HTTPS--&amp;gt;  [NPM : TLS]  --&amp;gt;  [registry container]  --&amp;gt;  [S3-compatible object storage]
                                                                  ^
[Droplet deploy]  --pull over HTTPS----------------------------- /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registry port is never exposed. NPM is the only thing that talks to it, and it does so over the internal Docker network.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A droplet running Docker and Nginx Proxy Manager&lt;/li&gt;
&lt;li&gt;A DNS A record &lt;code&gt;registry.yourdomain.com&lt;/code&gt; → your droplet's public IP&lt;/li&gt;
&lt;li&gt;An S3-compatible object storage bucket (DigitalOcean Spaces works well here since the registry ships with a native S3 driver) plus an access key / secret pair&lt;/li&gt;
&lt;li&gt;Your cloud firewall allowing only ports &lt;code&gt;80&lt;/code&gt;, &lt;code&gt;443&lt;/code&gt;, and &lt;code&gt;22&lt;/code&gt; and &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;5000&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Find your NPM Docker network
&lt;/h2&gt;

&lt;p&gt;Everything hangs off getting the network name right, so grab it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'proxy|npm'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the name it prints. Wherever you see &lt;code&gt;npm-network&lt;/code&gt; below, substitute yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The registry compose file
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/home/apps/registry/docker-compose.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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;registry&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;registry:2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry&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;always&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm-network&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;127.0.0.1:5000:5000"&lt;/span&gt;     &lt;span class="c1"&gt;# localhost only, for on-server curl tests; NPM reaches it via the network, not this&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;REGISTRY_HTTP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${REGISTRY_HTTP_SECRET}"&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_AUTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;htpasswd&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_AUTH_HTPASSWD_REALM&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Private&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Registry"&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_AUTH_HTPASSWD_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/auth/htpasswd&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_DELETE_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;     &lt;span class="c1"&gt;# required for garbage collection to reclaim space&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;        &lt;span class="c1"&gt;# dummy value; some S3-compatible providers ignore it but the driver requires it&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_REGIONENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://nyc3.digitaloceanspaces.com&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-registry-bucket&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_ACCESSKEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${SPACES_KEY}"&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_SECRETKEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${SPACES_SECRET}"&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_SECURE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;REGISTRY_STORAGE_S3_FORCEPATHSTYLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;   &lt;span class="c1"&gt;# safest with Spaces-style endpoints (avoids vhost/SNI cert issues)&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;./auth:/auth:ro&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json-file"&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm-network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of these lines matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;REGISTRY_STORAGE_DELETE_ENABLED: "true"&lt;/code&gt; without this, garbage collection cannot reclaim anything. The registry will happily grow forever.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;REGISTRY_STORAGE_S3_FORCEPATHSTYLE: "true"&lt;/code&gt; path-style requests avoid the virtual-host / SNI certificate mismatches you can hit with some S3-compatible endpoints.&lt;/li&gt;
&lt;li&gt;The bind is &lt;code&gt;127.0.0.1:5000:5000&lt;/code&gt;, not &lt;code&gt;5000:5000&lt;/code&gt;. That published port exists only so you can &lt;code&gt;curl&lt;/code&gt; the registry locally for testing. NPM does not use it — it reaches the container over the Docker network by name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prefer to start on local disk?&lt;/strong&gt; Delete every &lt;code&gt;REGISTRY_STORAGE_S3_*&lt;/code&gt; line and the &lt;code&gt;REGISTRY_STORAGE: s3&lt;/code&gt; line, then add &lt;code&gt;- ./data:/var/lib/registry&lt;/code&gt; under &lt;code&gt;volumes&lt;/code&gt;. You can migrate to object storage later without changing anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Bring it up on the server
&lt;/h2&gt;

&lt;p&gt;Paste this block. Edit the storage keys in the generated &lt;code&gt;.env&lt;/code&gt; afterward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/apps/registry/auth &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /home/apps/registry

&lt;span class="c"&gt;# create the .env (edit the storage keys after)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
REGISTRY_HTTP_SECRET=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
SPACES_KEY=your_access_key
SPACES_SECRET=your_secret_key
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# basic-auth user — bcrypt (-B) is mandatory for the registry's htpasswd&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apache2-utils
htpasswd &lt;span class="nt"&gt;-Bc&lt;/span&gt; /home/apps/registry/auth/htpasswd ci-pusher    &lt;span class="c"&gt;# prompts for a password -&amp;gt; this becomes REGISTRY_PASSWORD&lt;/span&gt;

docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 registry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The password you set for &lt;code&gt;ci-pusher&lt;/code&gt; is what GitHub Actions and your server will use to log in. Keep it handy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Configure Nginx Proxy Manager
&lt;/h2&gt;

&lt;p&gt;In the NPM UI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosts → Proxy Hosts → Add Proxy Host&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain Names: &lt;code&gt;registry.yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scheme: &lt;code&gt;http&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Forward Hostname: &lt;code&gt;registry&lt;/code&gt; (the container name)&lt;/li&gt;
&lt;li&gt;Forward Port: &lt;code&gt;5000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Block Common Exploits: on&lt;/li&gt;
&lt;li&gt;Websockets Support: off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SSL tab&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSL Certificate: &lt;em&gt;Request a new SSL Certificate&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Force SSL: on&lt;/li&gt;
&lt;li&gt;HTTP/2 Support: on&lt;/li&gt;
&lt;li&gt;Agree to the Let's Encrypt terms, then Save.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Advanced tab&lt;/strong&gt;: paste exactly this, and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one directive is non-negotiable. NPM's default request body cap is around 1 MB, and image layers are far bigger, without lifting the cap, pushes fail with &lt;code&gt;HTTP 413 Request Entity Too Large&lt;/code&gt;. Resist the urge to also paste &lt;code&gt;proxy_set_header&lt;/code&gt; lines here; NPM already sets them, and adding duplicates is exactly what causes header conflicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Smoke test
&lt;/h2&gt;

&lt;p&gt;From the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; ci-pusher:YOURPASS https://registry.yourdomain.com/v2/_catalog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a full round trip from your laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker login registry.yourdomain.com &lt;span class="nt"&gt;-u&lt;/span&gt; ci-pusher
docker pull hello-world
docker tag hello-world registry.yourdomain.com/test:1
docker push registry.yourdomain.com/test:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the push succeeds, TLS, auth, the body-size cap, and object storage are all working together. Double-check the cloud firewall still exposes only &lt;code&gt;80&lt;/code&gt;, &lt;code&gt;443&lt;/code&gt;, and &lt;code&gt;22&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The GitHub Actions pipeline
&lt;/h2&gt;

&lt;p&gt;This is a four-stage pipeline: audit dependencies, build and push, scan the pushed image for CVEs, then deploy over SSH. It assumes an upstream quality-check workflow triggers it, but you can change the trigger to a plain &lt;code&gt;push&lt;/code&gt; on &lt;code&gt;main&lt;/code&gt; if you prefer.&lt;/p&gt;

&lt;p&gt;Add two repository secrets first (&lt;strong&gt;Settings → Secrets and variables → Actions&lt;/strong&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;REGISTRY_USERNAME&lt;/code&gt; = &lt;code&gt;ci-pusher&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;REGISTRY_PASSWORD&lt;/code&gt; = the password you set for that user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You will also need the usual deploy secrets: &lt;code&gt;SERVER_IP&lt;/code&gt;, &lt;code&gt;SERVER_USER&lt;/code&gt;, and &lt;code&gt;SERVER_SSH_KEY&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;Build and Deploy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&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;CodeQuality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Checks"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;completed&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="s"&gt;main&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;REGISTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.yourdomain.com&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-web&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dependency-check&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;Dependency Vulnerability Check&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;${{ github.event.workflow_run.conclusion == 'success' }}&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&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;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&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;Set up Node.js&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&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;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&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;Audit dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm audit --audit-level=high&lt;/span&gt;

  &lt;span class="na"&gt;build-and-push&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;Build &amp;amp; Push Image&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dependency-check&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
    &lt;span class="na"&gt;outputs&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;${{ steps.image-name.outputs.image }}&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;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&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;Set image name&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-name&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "image=${{ env.REGISTRY }}/web-app" &amp;gt;&amp;gt; $GITHUB_OUTPUT&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;Set up Docker Buildx&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v4&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;Log in to registry&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_PASSWORD }}&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;Extract Docker image metadata&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/metadata-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.image-name.outputs.image }}&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;type=sha,prefix=sha-&lt;/span&gt;
            &lt;span class="s"&gt;type=raw,value=latest,enable={{is_default_branch}}&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;Build and push Docker image&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./Dockerfile&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.tags }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.labels }}&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
          &lt;span class="c1"&gt;# Public client-side build vars are fine to bake in here.&lt;/span&gt;
          &lt;span class="c1"&gt;# Never pass real server-side secrets as build-args — they persist in image layers.&lt;/span&gt;
          &lt;span class="na"&gt;build-args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;NEXT_PUBLIC_SOME_KEY=${{ secrets.NEXT_PUBLIC_SOME_KEY }}&lt;/span&gt;
            &lt;span class="s"&gt;NEXT_PUBLIC_SOME_ID=${{ secrets.NEXT_PUBLIC_SOME_ID }}&lt;/span&gt;

  &lt;span class="na"&gt;image-scan&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;Container Security Scan&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-push&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&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;Scan image with Trivy&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25&lt;/span&gt; &lt;span class="c1"&gt;# pin the action by SHA&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;TRIVY_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;TRIVY_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_PASSWORD }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build-and-push.outputs.image }}:latest&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CRITICAL,HIGH&lt;/span&gt;
          &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
          &lt;span class="na"&gt;ignore-unfixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Server&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;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;image-scan&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Server via SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&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;REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_PASSWORD }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_IP }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;envs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REGISTRY_PASSWORD&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;echo "$REGISTRY_PASSWORD" | docker login registry.yourdomain.com -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin&lt;/span&gt;
            &lt;span class="s"&gt;cd /home/apps/web-app&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f docker-compose.yml pull&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f docker-compose.yml up -d --remove-orphans&lt;/span&gt;
            &lt;span class="s"&gt;docker image prune -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out because they trip people up on the migration away from a hosted registry:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trivy scan needs credentials.&lt;/strong&gt; Your image is private now, so the scanner has to authenticate to pull it. That is what the &lt;code&gt;TRIVY_USERNAME&lt;/code&gt; / &lt;code&gt;TRIVY_PASSWORD&lt;/code&gt; env vars are for. Forgetting them produces a confusing "manifest unknown" style failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No more &lt;code&gt;packages: write&lt;/code&gt; permissions block.&lt;/strong&gt; Those scopes existed to let the built-in token push to the hosted registry. Talking to your own registry uses your &lt;code&gt;REGISTRY_USERNAME&lt;/code&gt; / &lt;code&gt;REGISTRY_PASSWORD&lt;/code&gt; secrets instead, so those permission blocks are dead weight and can go.&lt;/p&gt;

&lt;p&gt;Also note the deploy step relies on &lt;code&gt;docker compose pull&lt;/code&gt; plus &lt;code&gt;pull_policy: always&lt;/code&gt; rather than a standalone &lt;code&gt;docker pull&lt;/code&gt;. Doing it in one place removes a whole class of "the compose file and the pipeline disagree about which tag to run" bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: The application compose file
&lt;/h2&gt;

&lt;p&gt;On the server, the app's &lt;code&gt;docker-compose.yml&lt;/code&gt; only needs its image line pointed at the new registry:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web-app&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;registry.yourdomain.com/web-app:latest&lt;/span&gt;
    &lt;span class="na"&gt;pull_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web-app&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;${HOST_PORT:-3000}:3000"&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;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json-file"&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&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;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-verbose"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--tries=1"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--spider"&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:3000"&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;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One nice optimization: add a hosts entry so the server resolves the registry domain to itself instead of hairpinning out to the public IP and back. TLS still validates because the SNI name matches the certificate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"127.0.0.1 registry.yourdomain.com"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 8: Garbage collection
&lt;/h2&gt;

&lt;p&gt;Nothing reclaims disk (or object storage) automatically. Deleting a tag only removes the reference; the underlying blobs linger until you sweep them. Schedule that sweep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/cron.d/registry-gc &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
30 3 * * 0 root docker exec registry bin/registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs every Sunday at 03:30. One important caveat: garbage collection can delete a blob that an in-flight push is depending on, corrupting that push. Running it in a quiet window is the simple mitigation. If you ever push around the clock, put the registry into read-only mode for the duration of the sweep instead set &lt;code&gt;REGISTRY_STORAGE_MAINTENANCE_READONLY_ENABLED=true&lt;/code&gt;, run GC, then flip it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops being enough
&lt;/h2&gt;

&lt;p&gt;Bare Distribution deliberately does not do a few things that larger organizations eventually want: image signing and policy enforcement, team-level RBAC, cross-region replication, a web UI, and audit logs. If you need those, the move is not to abandon self-hosting it is to run Harbor, which is itself self-hosted and CNCF-graduated. It bundles its own registry, database, and vulnerability scanner, and realistically wants a couple of gigabytes of RAM and more moving parts.&lt;/p&gt;

&lt;p&gt;For a single-organization setup with a handful of projects, though, &lt;code&gt;registry:2&lt;/code&gt; + object storage + auth + scheduled GC is a completely legitimate production answer. The registry engine is the same one the big players use; whether &lt;em&gt;your&lt;/em&gt; deployment is production-grade comes down to the three things this guide made sure to cover durable storage, TLS with authentication, and garbage collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  A couple of upgrades to consider
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pin deploys to the immutable SHA tag.&lt;/strong&gt; &lt;code&gt;latest&lt;/code&gt; is ambiguous and makes rollbacks guesswork. The pipeline above already produces a &lt;code&gt;sha-&amp;lt;commit&amp;gt;&lt;/code&gt; tag on every build passing that SHA through to the compose &lt;code&gt;image:&lt;/code&gt; line gives you deterministic, reversible deploys. When something breaks, you redeploy the previous SHA instead of hoping &lt;code&gt;latest&lt;/code&gt; still points where you think it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship the registry's logs to wherever your other logs live.&lt;/strong&gt; If you already run a log-aggregation stack, point a collector at the &lt;code&gt;registry&lt;/code&gt; container so pushes, pulls, and GC runs show up alongside everything else. A registry is quiet right up until it is not, and having its logs where you already look saves you a bad afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The takeaway is that "self-hosted registry" and "what serious infrastructure uses" are not opposite ends of a spectrum they share the same core. The gap is entirely operational, and it is a gap you can close in an afternoon: bind to localhost, let a reverse proxy own TLS, authenticate both directions, put the bytes in durable storage, and sweep the garbage on a schedule.&lt;/p&gt;

&lt;p&gt;Do that, and you have a private registry that costs you nothing beyond the storage you actually use, lives next to the servers that pull from it, and answers to no one else's free-tier limits.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>cicd</category>
      <category>digitalocean</category>
    </item>
    <item>
      <title>Quick system design question: Do you actually know the structural difference between a Reverse Proxy, a Load Balancer, and an API Gateway? (Hint: They aren't the same thing!).</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Mon, 13 Jul 2026 11:08:23 +0000</pubDate>
      <link>https://dev.to/saint_vandora/quick-system-design-question-do-you-actually-know-the-structural-difference-between-a-reverse-5fga</link>
      <guid>https://dev.to/saint_vandora/quick-system-design-question-do-you-actually-know-the-structural-difference-between-a-reverse-5fga</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl" class="crayons-story__hidden-navigation-link"&gt;Reverse Proxy vs Load Balancer vs API Gateway: The Real Difference&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/saint_vandora" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" alt="saint_vandora profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/saint_vandora" class="crayons-story__secondary fw-medium m:hidden"&gt;
              FOLASAYO SAMUEL OLAYEMI
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                FOLASAYO SAMUEL OLAYEMI
                
              
              &lt;div id="story-author-preview-content-4132611" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/saint_vandora" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;FOLASAYO SAMUEL OLAYEMI&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 13&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl" id="article-link-4132611"&gt;
          Reverse Proxy vs Load Balancer vs API Gateway: The Real Difference
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/systemdesign"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;systemdesign&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/api"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;api&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/architecture"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;architecture&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            7 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>architecture</category>
      <category>backend</category>
      <category>networking</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Reverse Proxy vs Load Balancer vs API Gateway: The Real Difference</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Mon, 13 Jul 2026 11:06:37 +0000</pubDate>
      <link>https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl</link>
      <guid>https://dev.to/saint_vandora/reverse-proxy-vs-load-balancer-vs-api-gateway-the-real-difference-4afl</guid>
      <description>&lt;p&gt;Imagine you have built a backend server that handles requests perfectly in development. It easily survives a few hundred users. Then, your application gets picked up on social media, and suddenly 10,000 requests hit your server at the exact same second.&lt;/p&gt;

&lt;p&gt;Connections pile up, requests time out, CPU usage spikes, and users are stuck staring at loading screens or a dreaded &lt;code&gt;502 Bad Gateway&lt;/code&gt; error.&lt;/p&gt;

&lt;p&gt;Most engineers know the obvious fix: add more servers or put &lt;em&gt;something&lt;/em&gt; in front of the backend. But what exactly goes in front? The moment you enter the realm of system design, you hear three terms used interchangeably: &lt;strong&gt;Reverse Proxy&lt;/strong&gt;, &lt;strong&gt;Load Balancer&lt;/strong&gt;, and &lt;strong&gt;API Gateway&lt;/strong&gt;. Even experienced engineers mix them up because they all sit between users and servers. However, they exist for completely different reasons, protect against different failures, and solve distinct scaling problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Starting Point: Direct Connection (Layer 0)
&lt;/h2&gt;

&lt;p&gt;In the simplest version of the web, a client sends a request directly to a backend server, and the server sends back a response. This works fine until your production environment starts taking heavy traffic.&lt;/p&gt;

&lt;p&gt;When a server sits directly on the public internet, it must handle everything itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS/SSL Encryption:&lt;/strong&gt; Every HTTPS request begins with a TLS handshake, which requires expensive cryptographic calculations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static Files &amp;amp; Business Logic:&lt;/strong&gt; The server must fetch database records, compress responses, and serve static images simultaneously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Risks:&lt;/strong&gt; The server's IP address is entirely public in DNS records. Anyone can scan it, probe it, or launch a direct attack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is like asking a surgeon to perform complex surgery while simultaneously managing patient intake, sterilizing equipment, answering phone calls, and handling billing. Eventually, the core surgery suffers.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Protective Buffer: Reverse Proxy
&lt;/h2&gt;

&lt;p&gt;To fix the vulnerabilities of a direct connection, engineers introduce a protective layer at the edge of the internet: the &lt;strong&gt;Reverse Proxy&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forward Proxy vs. Reverse Proxy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forward Proxy (Client-Side):&lt;/strong&gt; Works on behalf of the client. Examples include VPNs or IP-masking tools. They sit in front of a user to hide their identity from the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse Proxy (Server-Side):&lt;/strong&gt; Works on behalf of the server. It sits in front of the backend infrastructure. Clients talk to the proxy's address, and the proxy decides where to route the request.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Clients ]  ───&amp;gt;  [ Reverse Proxy ]  ───(Trusted Private Network)───&amp;gt;  [ Backend Server ]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Core Responsibilities of a Reverse Proxy
&lt;/h3&gt;

&lt;p&gt;By placing a tool like &lt;strong&gt;Nginx, HAProxy, Caddy, or Envoy&lt;/strong&gt; in front of your backend, you can offload heavy infrastructure tasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSL Termination:&lt;/strong&gt; The proxy handles the CPU-heavy cryptographic work of TLS handshakes at the edge. It then passes plain HTTP to the backend over a trusted, private internal network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching:&lt;/strong&gt; If an API returns the same product catalog to 1,000 users, the proxy saves the first response in memory. The next 999 requests are served instantly from the cache without waking up the backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression:&lt;/strong&gt; The proxy compresses payloads using algorithms like Gzip or Brotli before they leave, lowering bandwidth usage and reducing backend CPU strain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anonymity &amp;amp; Security:&lt;/strong&gt; Your application server's IP address stays hidden. You can handle rate limiting, header enforcement, and block malicious patterns right at the proxy layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Key Insight:&lt;/strong&gt; A reverse proxy is general purpose. It operates primarily at the connection and routing level. It does &lt;em&gt;not&lt;/em&gt; understand business logic, user authentication, permissions, or API versions; it simply forwards traffic based on static routing rules.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Scaling Horizontally: The Load Balancer
&lt;/h2&gt;

&lt;p&gt;Even with a reverse proxy handling SSL and caching, a single backend server has physical limits on CPU, memory, and concurrent network connections. When traffic triples, you must scale horizontally by adding more servers (e.g., Server A, Server B, Server C).&lt;/p&gt;

&lt;p&gt;This introduces new structural problems: How do you distribute traffic evenly? What happens if one server crashes?&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Load Balancer&lt;/strong&gt; is essentially a reverse proxy that has evolved one highly specialized skill: &lt;strong&gt;intelligent traffic distribution&lt;/strong&gt;. It tracks server health and decides exactly where to send each incoming request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Traffic Distribution Strategies
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Round Robin:&lt;/strong&gt; Passes requests sequentially (Server A -&amp;gt; Server B -&amp;gt; Server C -&amp;gt; repeat). It works best when all servers have equal hardware specifications and requests require similar processing power.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least Connections:&lt;/strong&gt; Tracks which backend server is currently handling the fewest active requests and shifts traffic there. This is ideal for systems where some requests trigger heavy database queries while others finish instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weighted Round Robin:&lt;/strong&gt; Assigns capacity scores based on hardware capability. A robust 64GB RAM machine will intentionally receive significantly more traffic than a smaller 16GB instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP Hashing:&lt;/strong&gt; Uses the client's IP address to consistently route them to the same backend server. This is occasionally used for session affinity, though modern distributed systems prefer stateless architectures.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Layer 4 vs. Layer 7 Load Balancing
&lt;/h3&gt;

&lt;p&gt;Load balancers operate at different layers of the Open Systems Interconnection (OSI) model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Layer 4 (Transport Level)&lt;/th&gt;
&lt;th&gt;Layer 7 (Application Level)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Understands TCP connections, IP addresses, and ports. Blind to HTTP data.&lt;/td&gt;
&lt;td&gt;Inspects full HTTP traffic, including URLs, headers, and cookies.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incredibly fast and memory efficient; handles raw packet streams.&lt;/td&gt;
&lt;td&gt;Slightly higher processing overhead due to parsing HTTP payloads.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Routing Ability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Can only route to a target pool based on IP/Port data.&lt;/td&gt;
&lt;td&gt;Can route &lt;code&gt;/api/users&lt;/code&gt; to one cluster and &lt;code&gt;/payments&lt;/code&gt; to a highly secure cluster.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS Analogue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Network Load Balancer (NLB)&lt;/td&gt;
&lt;td&gt;Application Load Balancer (ALB)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  High Availability through Health Checks
&lt;/h3&gt;

&lt;p&gt;The defining feature of a load balancer is &lt;strong&gt;Health Checking&lt;/strong&gt;. It continuously pings backend servers to confirm they are alive. If a server crashes, the load balancer immediately pulls it out of the rotation pool. Traffic is automatically rerouted to healthy machines without any human intervention, preventing system downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Decoupling Microservices: The API Gateway
&lt;/h2&gt;

&lt;p&gt;As your application grows, monolithic codebases often become risky to deploy. To solve this, engineering teams split the system into &lt;strong&gt;microservices&lt;/strong&gt; (e.g., a User service, Order service, Payment service, and Notification service).&lt;/p&gt;

&lt;p&gt;While this allows individual teams to build and deploy independently, it creates duplicate infrastructure problems. Suddenly, every microservice needs its own code to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate JWTs, check user permissions, and verify API keys.&lt;/li&gt;
&lt;li&gt;Implement rate-limiting to prevent traffic abuse.&lt;/li&gt;
&lt;li&gt;Track latency, log errors, and expose metrics consistently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If every team implements these features independently, you end up with 12 separate copies of infrastructure logic that gradually drift apart, creating security vulnerabilities and code maintenance headaches.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;API Gateway&lt;/strong&gt; solves this by acting as a reverse proxy that actually &lt;strong&gt;understands your APIs&lt;/strong&gt;. It serves as a unified entry point that orchestrates cross-cutting concerns at the edge before requests hit your services.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Client ] ──&amp;gt; [ API Gateway ] ──┬──&amp;gt; [ User Service Pool ]
                                 ├──&amp;gt; [ Order Service Pool ]
                                 └──&amp;gt; [ Payment Service Pool ]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Advanced Features of an API Gateway
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Centralized Authentication:&lt;/strong&gt; The gateway validates tokens once at the perimeter. Malformed or unauthenticated requests are rejected immediately, freeing backend services to focus entirely on core business logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced Rate Limiting &amp;amp; Quotas:&lt;/strong&gt; Tiered limits can be applied centrally. For example, free-tier accounts might be limited to 100 requests per minute, while enterprise users get 10,000, managed outside the application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request &amp;amp; Response Transformation:&lt;/strong&gt; The gateway can translate data formats on the fly, such as converting a modern mobile client's JSON request into an older legacy service's expected XML payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Versioning &amp;amp; Blue/Green Migrations:&lt;/strong&gt; You can gracefully migrate from &lt;code&gt;/v1&lt;/code&gt; to &lt;code&gt;/v2&lt;/code&gt; APIs at the gateway layer. The gateway silently routes &lt;code&gt;/v1&lt;/code&gt; requests to legacy servers while seamlessly directing newer clients to updated microservices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified Observability:&lt;/strong&gt; Since all traffic traverses a single point, the gateway offers a complete architectural view of error rates, traffic spikes, and localized latency issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Popular dedicated API Gateway tools include &lt;strong&gt;Kong, AWS API Gateway, Apigee, and Tyke&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Why the Terms Blur: The Feature Spectrum
&lt;/h2&gt;

&lt;p&gt;Engineers frequently mix these terms up because modern software tools do not strictly respect theoretical boundaries. The tools often wear multiple hats depending on how they are configured.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nginx:&lt;/strong&gt; Began as a reverse proxy. However, by adding an &lt;code&gt;upstream&lt;/code&gt; block, it transforms into a load balancer. By adding Lua plugins or OpenResty extensions, it can handle JWT validation and rate limiting, functioning as an API gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kong:&lt;/strong&gt; Marketed as an API gateway, but it is built directly on top of Nginx. It relies internally on reverse proxy mechanics and load balancing algorithms to fulfill its gateway duties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Services:&lt;/strong&gt; AWS offers both an Application Load Balancer (ALB) and an API Gateway. While conceptually separate, they overlap; an ALB can handle content-based path routing, and an API Gateway natively distributes traffic across server pools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of thinking of these as isolated product categories, view them as a &lt;strong&gt;spectrum of capabilities&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Reverse Proxy ] ───────────────&amp;gt; [ Load Balancer ] ────────────────&amp;gt; [ API Gateway ]
  - SSL Termination                  - Traffic Distribution            - Auth &amp;amp; Permissions
  - Content Caching                  - Server Health Checks            - Tiered Rate Limiting
  - Payload Compression              - Horizontal Scaling              - API Versioning
  - IP Masking                       - Failover Routing                - Data Transformation

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. How They Layer Together in Production
&lt;/h2&gt;

&lt;p&gt;In production systems serving millions of users, you rarely choose just one tool. Instead, you layer them sequentially because they solve entirely different problems.&lt;/p&gt;

&lt;p&gt;Here is what happens when a user triggers a dynamic request inside an enterprise application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ User ] 
   │
   ▼
[ Content Delivery Network (CDN) ]  &amp;lt;-- Global Edge Reverse Proxy (Caches static files/SSL)
   │ (Cache Miss / Dynamic Request)
   ▼
[ API Gateway ]                     &amp;lt;-- Evaluates Auth, Rate Limits, and API Routing
   │ (e.g., Path: /api/payments)
   ▼
[ Service Load Balancer ]           &amp;lt;-- Balances traffic across the Payment cluster
   │ (Chooses healthiest node)
   ▼
[ Service Instance (Proxy + App) ]  &amp;lt;-- Internal Envoy/Nginx proxy handles local TLS/compression

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CDN Layer:&lt;/strong&gt; The request first hits a CDN (like Cloudflare or Fastly), which functions as a globally distributed network of reverse proxies. It serves static assets locally and terminates SSL close to the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The API Gateway Layer:&lt;/strong&gt; Dynamic requests pass through to the origin infrastructure's API Gateway. The gateway checks API keys, confirms rate limits, verifies authentication, and handles routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Cluster Load Balancer:&lt;/strong&gt; The gateway passes the request to the specific service pool (e.g., the Payment Service). A dedicated load balancer sits in front of that service to distribute the request to one of several running instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Local Service Proxy:&lt;/strong&gt; Even inside the server instance, a lightweight reverse proxy (like Envoy or Nginx) might run alongside the code to compress responses or manage secure internal service-to-service mesh communications.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary Checklist: Which One Do You Need?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose a Reverse Proxy (e.g., Nginx, Caddy)&lt;/strong&gt; if you have a single backend server and need basic security, SSL termination, payload compression, or static content caching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose a Load Balancer (e.g., HAProxy, AWS ALB)&lt;/strong&gt; if your traffic has outgrown a single machine and you need to scale horizontally across multiple identical backend servers with automated health checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose an API Gateway (e.g., Kong, Tyke)&lt;/strong&gt; if you are managing complex public APIs or microservices that require central management for authentication, versioning, data transformations, and billing tiers.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>systemdesign</category>
      <category>tutorial</category>
      <category>api</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Setting Up a Production CI/CD Pipeline for a Python/Django App</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:38:07 +0000</pubDate>
      <link>https://dev.to/saint_vandora/setting-up-a-production-cicd-pipeline-for-a-pythondjango-app-593d</link>
      <guid>https://dev.to/saint_vandora/setting-up-a-production-cicd-pipeline-for-a-pythondjango-app-593d</guid>
      <description>&lt;p&gt;A practical walkthrough of building a complete GitHub Actions pipeline for a Django project, from a multi-stage Dockerfile through vulnerability scanning to zero-downtime deploys via SSH. This is the exact four-job pattern I now use across every Python service I ship, adapted from the same standard I run for Node/React projects.&lt;/p&gt;

&lt;p&gt;The end state: push to &lt;code&gt;develop&lt;/code&gt;, and within a few minutes you have a scanned, versioned image running on your server, with your database and cache layers never touched by the automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of the Pipeline
&lt;/h2&gt;

&lt;p&gt;Four jobs, each gating the next:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dependency check&lt;/strong&gt;: fail fast on known-vulnerable packages before you spend CI minutes building&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build &amp;amp; push&lt;/strong&gt;: compile the image once, tag it with the exact commit SHA, push to GHCR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image scan&lt;/strong&gt;: Trivy scans the &lt;em&gt;built&lt;/em&gt; image for OS and package CVEs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: SSH into the server, pull the exact scanned tag, restart only the application containers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each job only runs if the previous one succeeds. Nothing reaches production without passing every gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The Dockerfile
&lt;/h2&gt;

&lt;p&gt;Python images benefit enormously from a multi-stage build, because compiling native extensions (&lt;code&gt;psycopg2&lt;/code&gt;, &lt;code&gt;Pillow&lt;/code&gt;, &lt;code&gt;lxml&lt;/code&gt;, and friends) needs a full build toolchain that has no business existing in your production image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# =========================&lt;/span&gt;
&lt;span class="c"&gt;# 1. Base Stage&lt;/span&gt;
&lt;span class="c"&gt;# Shared env/config for both builder and runtime, kept DRY&lt;/span&gt;
&lt;span class="c"&gt;# so these settings can't drift out of sync between stages.&lt;/span&gt;
&lt;span class="c"&gt;# =========================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.9-slim-bookworm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 \&lt;/span&gt;
    PYTHONUNBUFFERED=1

&lt;span class="c"&gt;# =========================&lt;/span&gt;
&lt;span class="c"&gt;# 2. Builder Stage&lt;/span&gt;
&lt;span class="c"&gt;# Compiles wheels for all deps requiring native extensions&lt;/span&gt;
&lt;span class="c"&gt;# so the runtime image doesn't need a full build toolchain.&lt;/span&gt;
&lt;span class="c"&gt;# =========================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    build-essential gcc g++ make pkg-config &lt;span class="se"&gt;\
&lt;/span&gt;    libpq-dev libffi-dev libmagic-dev libssl-dev zlib1g-dev &lt;span class="se"&gt;\
&lt;/span&gt;    libjpeg-dev libxml2-dev libxslt1-dev &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements ./requirements&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip setuptools wheel
&lt;span class="k"&gt;RUN &lt;/span&gt;pip wheel &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--wheel-dir&lt;/span&gt; /wheels &lt;span class="nt"&gt;-r&lt;/span&gt; requirements/prod.txt

&lt;span class="c"&gt;# =========================&lt;/span&gt;
&lt;span class="c"&gt;# 3. Runtime Stage&lt;/span&gt;
&lt;span class="c"&gt;# Only the shared libs the compiled wheels actually need.&lt;/span&gt;
&lt;span class="c"&gt;# no compilers, no -dev headers.&lt;/span&gt;
&lt;span class="c"&gt;# =========================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    libpq5 libmagic1 libjpeg62-turbo libxml2 libxslt1.1 netcat-openbsd &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /wheels /wheels&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements/prod.txt ./requirements/prod.txt&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--no-index&lt;/span&gt; &lt;span class="nt"&gt;--find-links&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/wheels &lt;span class="nt"&gt;-r&lt;/span&gt; requirements/prod.txt &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /wheels requirements

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; docker/prod/entrypoint.sh /entrypoint.sh&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /entrypoint.sh
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; 1001 django &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 1001 &lt;span class="nt"&gt;--gid&lt;/span&gt; 1001 django &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; django:django /app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; django&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8000&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/entrypoint.sh"]&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a shared &lt;code&gt;base&lt;/code&gt; stage?&lt;/strong&gt; Both &lt;code&gt;builder&lt;/code&gt; and &lt;code&gt;runtime&lt;/code&gt; need the same &lt;code&gt;WORKDIR&lt;/code&gt; and &lt;code&gt;ENV&lt;/code&gt; settings. Declaring them twice means they can silently drift apart. One base stage, two things extending it. Same idea as a shared config file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;--no-index --find-links=/wheels&lt;/code&gt; instead of just &lt;code&gt;pip install -r requirements&lt;/code&gt;?&lt;/strong&gt; The runtime stage never touches the internet or a package index. It installs &lt;em&gt;only&lt;/em&gt; the exact wheels the builder already compiled. This makes builds reproducible and avoids the runtime stage accidentally pulling in a different resolved version than what was actually tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a non-root user?&lt;/strong&gt; Running as &lt;code&gt;django&lt;/code&gt; (uid 1001) rather than root limits blast radius if the container is ever compromised. &lt;code&gt;chown -R&lt;/code&gt; on the app directory before switching users is required, or the process won't have permission to write anything (log files, temp files, etc.).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never bake &lt;code&gt;runserver&lt;/code&gt; into a production image.&lt;/strong&gt; Django's development server isn't built for concurrent connections or production traffic. Gunicorn, already installed via &lt;code&gt;requirements/prod.txt&lt;/code&gt; in most Django boilerplates, is the standard WSGI server for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: A Real Entrypoint
&lt;/h2&gt;

&lt;p&gt;The entrypoint's only jobs: wait for the database to be reachable, apply already-committed migrations, then hand off to whatever command the container was actually started with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Waiting for PostgreSQL at &lt;/span&gt;&lt;span class="nv"&gt;$SQL_HOST&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$SQL_PORT&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SQL_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SQL_PORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;0.1
  &lt;span class="k"&gt;done
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PostgreSQL is up."&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;python manage.py migrate &lt;span class="nt"&gt;--no-input&lt;/span&gt;

&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right here, because both are easy to get subtly wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set -e&lt;/code&gt;&lt;/strong&gt; so the script stops on the first failure instead of limping forward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exec "$@"&lt;/code&gt;&lt;/strong&gt; at the end, not just &lt;code&gt;"$@"&lt;/code&gt;. Without &lt;code&gt;exec&lt;/code&gt;, your app runs as a child process of the shell script, which means it never receives signals like &lt;code&gt;SIGTERM&lt;/code&gt; directly, and Docker's graceful shutdown won't work correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notably absent: &lt;code&gt;makemigrations&lt;/code&gt;. That command should only ever run in development. Auto-generating schema changes at deploy time means production can apply migrations nobody reviewed. Migrations get written and committed in dev; production only ever runs &lt;code&gt;migrate&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The GitHub Actions Workflow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy My Django App&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;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&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;CodeQuality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Checks"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;completed&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="s"&gt;develop&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;REGISTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-my-django-app&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dependency-check&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;Dependency Vulnerability Check&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;${{ github.event.workflow_run.conclusion == 'success' }}&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.9'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install pip-audit&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip-audit -r requirements/prod.txt&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;pip-audit -r requirements/prod.txt&lt;/code&gt; instead of installing everything first?&lt;/strong&gt; &lt;code&gt;pip-audit&lt;/code&gt; can scan a requirements file directly against known vulnerability databases without needing a full working install. Faster, and it doesn't require your build toolchain just to run a security check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;workflow_run&lt;/code&gt; instead of triggering directly on push?&lt;/strong&gt; This chains the deploy pipeline behind a separate code-quality workflow (linting, tests); deploy only fires if that already succeeded. &lt;code&gt;if: ${{ github.event.workflow_run.conclusion == 'success' }}&lt;/code&gt; is the gate that enforces it.&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;build-and-push&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;Build &amp;amp; Push Image&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dependency-check&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;outputs&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;${{ steps.image-name.outputs.image }}&lt;/span&gt;
      &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha-${{ github.event.workflow_run.head_sha }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&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;Set lowercase image name&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-name&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;echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&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;Extract Docker image metadata&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/metadata-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.image-name.outputs.image }}&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;type=sha,prefix=sha-&lt;/span&gt;
            &lt;span class="s"&gt;type=raw,value=latest,enable={{is_default_branch}}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./docker/prod/Dockerfile&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.tags }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.labels }}&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The single most important line in this whole pipeline:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha-${{ github.event.workflow_run.head_sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gets exposed as a job output and threaded through every job after it. The alternative, assuming a &lt;code&gt;:latest&lt;/code&gt; tag exists and using it everywhere, silently breaks the moment your trigger branch isn't your repo's actual GitHub-configured default branch, because &lt;code&gt;docker/metadata-action&lt;/code&gt;'s &lt;code&gt;enable={{is_default_branch}}&lt;/code&gt; only tags &lt;code&gt;latest&lt;/code&gt; on the real default branch. Deploying by SHA means you always know exactly what commit is running in production, and rollbacks become "redeploy this specific tag" instead of guesswork.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions cache (&lt;code&gt;cache-from&lt;/code&gt;/&lt;code&gt;cache-to: type=gha&lt;/code&gt;)&lt;/strong&gt; persists Docker layer cache between runs. Your dependency-install layer won't rebuild from scratch every single push unless &lt;code&gt;requirements/prod.txt&lt;/code&gt; actually changed.&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;image-scan&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;Container Security Scan&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-push&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25&lt;/span&gt; &lt;span class="c1"&gt;# v0.36.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build-and-push.outputs.image }}:${{ needs.build-and-push.outputs.tag }}&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CRITICAL,HIGH&lt;/span&gt;
          &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
          &lt;span class="na"&gt;ignore-unfixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;exit-code: '1'&lt;/code&gt; is what makes this a real gate rather than a report nobody reads. The job fails, the pipeline stops, nothing gets deployed. &lt;code&gt;ignore-unfixed: true&lt;/code&gt; filters out CVEs with no available patch yet, since failing a build over something you can't currently fix just trains everyone to ignore the scanner.&lt;/p&gt;

&lt;p&gt;If this catches something, don't assume it's your application code. Base OS images (&lt;code&gt;python:3.9-slim-bookworm&lt;/code&gt; here) accumulate CVEs in their bundled packages over time too. A quick &lt;code&gt;apk update &amp;amp;&amp;amp; apk upgrade --no-cache&lt;/code&gt; (Alpine) or &lt;code&gt;apt-get update &amp;amp;&amp;amp; apt-get upgrade -y&lt;/code&gt; (Debian-based) at the top of your runtime stage often clears these without touching a single line of application code.&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;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Server&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;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;image-scan&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_IP }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin&lt;/span&gt;
            &lt;span class="s"&gt;docker pull ${{ needs.build-and-push.outputs.image }}:${{ needs.build-and-push.outputs.tag }}&lt;/span&gt;
            &lt;span class="s"&gt;cd /home/apps/my-django-app&lt;/span&gt;
            &lt;span class="s"&gt;IMAGE_TAG=${{ needs.build-and-push.outputs.tag }} docker compose -f docker-compose.prod.yml up -d --no-deps api celery celery-beat&lt;/span&gt;
            &lt;span class="s"&gt;docker image prune -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The line that matters most for anything with a database:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;docker compose ... up -d --no-deps api celery celery-beat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-deps&lt;/code&gt;, plus explicitly naming only the application services, means your database and cache containers are &lt;em&gt;never&lt;/em&gt; included in the recreate. Compare that to a blanket &lt;code&gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;, which tears down and recreates every service in the file, including your database, on every single deploy. One flag is the difference between "safe to deploy fifty times a day" and "one bad merge away from an outage."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The Production Compose File
&lt;/h2&gt;



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

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;api&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/your-org/your-app:${IMAGE_TAG:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-api&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;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers &lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./.env&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;8005:8000"&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;redis&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;celery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*api&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-celery&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;celery worker --app=core --loglevel=info&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="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;redis&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;api&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:12.1-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-db&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;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./.env&lt;/span&gt;

  &lt;span class="na"&gt;redis&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:alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-redis&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="na"&gt;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp;api&lt;/code&gt; / &lt;code&gt;&amp;lt;&amp;lt;: *api&lt;/code&gt; YAML anchor pattern means &lt;code&gt;celery&lt;/code&gt; inherits everything from &lt;code&gt;api&lt;/code&gt; (image, env file, restart policy) and only overrides what's different: the command and port mapping. One image built once, run with different startup commands for the web process versus the background worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;${IMAGE_TAG:-latest}&lt;/code&gt;&lt;/strong&gt; reads the &lt;code&gt;IMAGE_TAG&lt;/code&gt; environment variable the deploy step sets, falling back to &lt;code&gt;latest&lt;/code&gt; if it's ever run manually without that variable set, useful for local debugging on the server without breaking the syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Verify Before You Trust It
&lt;/h2&gt;

&lt;p&gt;Before pointing real traffic at this, worth confirming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Does the volume actually persist across recreations of the app containers?&lt;/span&gt;
docker volume &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;postgres_data

&lt;span class="c"&gt;# Does the entrypoint's DB wait-loop actually see the right env vars?&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; my-app-api &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"DATABASE|SQL_HOST|SQL_PORT"&lt;/span&gt;

&lt;span class="c"&gt;# Does a manual run of the compose file work before letting CI do it automatically?&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; api celery celery-beat
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml logs &lt;span class="nt"&gt;-f&lt;/span&gt; api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Required secrets in your repo settings: &lt;code&gt;SERVER_IP&lt;/code&gt;, &lt;code&gt;SERVER_USER&lt;/code&gt;, &lt;code&gt;SERVER_SSH_KEY&lt;/code&gt;, &lt;code&gt;GHCR_PAT&lt;/code&gt; (a personal access token with &lt;code&gt;read:packages&lt;/code&gt;, used for the server to authenticate against GHCR independently of whatever's cached in CI).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Shape, Specifically
&lt;/h2&gt;

&lt;p&gt;Every piece of this pipeline exists because of a failure mode it closes off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dependency check before build&lt;/strong&gt;: don't spend CI minutes building an image from packages you already know are vulnerable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SHA-based tags instead of &lt;code&gt;latest&lt;/code&gt;&lt;/strong&gt;: always know exactly what's running; rollbacks become trivial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image scan after build, before deploy&lt;/strong&gt;: you're scanning what will actually run, not just source code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--no-deps&lt;/code&gt; on deploy&lt;/strong&gt;: stateful services are structurally protected from an automation mistake, not just protected by convention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-root runtime user&lt;/strong&gt;: limits what an exploited container can actually do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stage Dockerfile&lt;/strong&gt;: smaller final image, no build toolchain shipped to production, faster pulls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these individually are complicated. Together, they're the difference between a deploy you have to babysit and one you can trust to run unattended, multiple times a day, without holding your breath.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Debugging a Legacy CRA + Django Deployment Pipeline: A DevOps Postmortem</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Sun, 12 Jul 2026 19:20:24 +0000</pubDate>
      <link>https://dev.to/saint_vandora/debugging-a-legacy-cra-django-deployment-pipeline-a-devops-postmortem-2epd</link>
      <guid>https://dev.to/saint_vandora/debugging-a-legacy-cra-django-deployment-pipeline-a-devops-postmortem-2epd</guid>
      <description>&lt;p&gt;A few weeks ago I was handed two deployment tasks that looked routine on paper: containerize and ship a React frontend, then do the same for its Django backend. Both apps were already running somewhere one on &lt;code&gt;manage.py runserver&lt;/code&gt;, the other via a dev Dockerfile nobody had touched in years. "Just make it production-ready" is a deceptively small sentence. Here's what actually happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: The Frontend That Wasn't Next.js
&lt;/h2&gt;

&lt;p&gt;The first Dockerfile I inherited looked like this at the runner stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/.next/static ./.next/static&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean multi-stage build, sensible layer caching, non-root-adjacent structure. One problem: the project was &lt;code&gt;create-react-app&lt;/code&gt; via &lt;code&gt;craco&lt;/code&gt;, not Next.js. &lt;code&gt;.next/standalone&lt;/code&gt; doesn't exist in a CRA build  there's no server to run. This was almost certainly a copy-paste from a Next.js project's Dockerfile that nobody adapted. The build would fail outright the moment it reached that &lt;code&gt;COPY&lt;/code&gt; step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson one:&lt;/strong&gt; before touching a Dockerfile, check what the build script actually produces. &lt;code&gt;"build": "craco build"&lt;/code&gt; outputs a static &lt;code&gt;build/&lt;/code&gt; directory. No amount of Dockerfile cleverness fixes a mismatched deployment model you have to match the artifact, which meant swapping the runner stage entirely to an nginx static file server instead of a Node process.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Node version trap
&lt;/h3&gt;

&lt;p&gt;With the runner fixed, the next failure was a native module rebuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error Command "rebuild" not found.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Dockerfile had &lt;code&gt;yarn install --frozen-lockfile --ignore-scripts&lt;/code&gt; followed by &lt;code&gt;yarn rebuild esbuild sharp&lt;/code&gt; except &lt;code&gt;rebuild&lt;/code&gt; isn't a Yarn Classic command, it's an npm one. Someone had disabled install scripts (probably for build speed or a supply-chain concern) and then tried to manually force native binaries to compile, using syntax from the wrong package manager entirely.&lt;/p&gt;

&lt;p&gt;The fix was almost too simple: drop &lt;code&gt;--ignore-scripts&lt;/code&gt;, let &lt;code&gt;yarn install&lt;/code&gt; run postinstall naturally, and delete the broken &lt;code&gt;rebuild&lt;/code&gt; line. But underneath that surface bug was a nastier one the &lt;code&gt;deps&lt;/code&gt; stage was building on &lt;code&gt;node:24-alpine&lt;/code&gt; while &lt;code&gt;builder&lt;/code&gt;/&lt;code&gt;runner&lt;/code&gt; ran &lt;code&gt;node:22-alpine&lt;/code&gt;. Native modules like &lt;code&gt;sharp&lt;/code&gt; and &lt;code&gt;esbuild&lt;/code&gt; compile against a specific Node ABI. Compile on 24, run on 22, and you risk a runtime crash that CI won't catch it only shows up when the container actually starts. Two completely different Node majors across stages had been quietly coexisting because &lt;code&gt;--ignore-scripts&lt;/code&gt; was skipping the native compile step that would have caught the mismatch immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson two:&lt;/strong&gt; every &lt;code&gt;FROM node:X-alpine&lt;/code&gt; in a multi-stage build should agree on X, unless you have a very specific reason otherwise. Silent ABI mismatches are the kind of bug that passes CI and fails in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenSSL, then postcss, then Node itself
&lt;/h3&gt;

&lt;p&gt;Once the build actually reached &lt;code&gt;yarn build&lt;/code&gt;, it hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: error:0308010C:digital envelope routines::unsupported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;react-scripts@4.0.3&lt;/code&gt; bundles Webpack 4, which uses Node's legacy MD4 hashing internals removed by default once Node moved to OpenSSL 3 (Node 17+). The standard fix, &lt;code&gt;NODE_OPTIONS=--openssl-legacy-provider&lt;/code&gt;, cleared that one.&lt;/p&gt;

&lt;p&gt;Then a second, unrelated error surfaced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/tokenize' is not defined by "exports"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one traced to &lt;code&gt;postcss-safe-parser&lt;/code&gt;, a transitive dependency of &lt;code&gt;react-scripts@4&lt;/code&gt;'s CSS minification chain, which ships its own nested, ancient copy of &lt;code&gt;postcss&lt;/code&gt;. That old &lt;code&gt;postcss&lt;/code&gt;'s &lt;code&gt;package.json&lt;/code&gt; never declared a wildcard &lt;code&gt;exports&lt;/code&gt; field. Node 17+ enforces &lt;code&gt;exports&lt;/code&gt; strictly and hard-errors on any path not explicitly declared. Node 16 only warned. Node 24 refused outright.&lt;/p&gt;

&lt;p&gt;I initially reached for the obvious fix downgrade the build stage to Node 16, the last version before this became a hard error. It worked, but it was the wrong call to standardize on, and I was right to get pushback on it. Node 16 is EOL; picking it just because it dodges a rule isn't a fix, it's postponing the problem. The better answer, once I thought about who actually owns what: &lt;strong&gt;this is application dependency debt, not infrastructure&lt;/strong&gt;. The real fix is a &lt;code&gt;yarn.lock&lt;/code&gt; change a developer should make pinning the nested &lt;code&gt;postcss&lt;/code&gt; via &lt;code&gt;resolutions&lt;/code&gt;. But as the DevOps engineer, pulling the repo and editing &lt;code&gt;package.json&lt;/code&gt; myself wasn't really my lane either.&lt;/p&gt;

&lt;p&gt;The answer that stuck: patch the broken nested &lt;code&gt;package.json&lt;/code&gt; from inside the Dockerfile itself, after &lt;code&gt;yarn install&lt;/code&gt;, using a small inline Node script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;  const fs = require('fs'); &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;  const path = 'node_modules/postcss-safe-parser/node_modules/postcss/package.json'; &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;  if (fs.existsSync(path)) { &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;    const pkg = JSON.parse(fs.readFileSync(path)); &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;    pkg.exports = pkg.exports || {}; &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;    pkg.exports['./lib/*'] = './lib/*.js'; &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;    fs.writeFileSync(path, JSON.stringify(pkg, null, 2)); &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s2"&gt;  }"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No lockfile change, no dev involvement, Node stays on 24 across the fleet. It's explicitly a stopgap if the dependency tree shifts and &lt;code&gt;postcss-safe-parser&lt;/code&gt; nests its &lt;code&gt;postcss&lt;/code&gt; copy somewhere else, this silently stops applying and the original error resurfaces. That's a feature, not a bug: it fails loud and traceable rather than papering over a moving target indefinitely. I flagged the underlying issue to the dev team as a proper fix for later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson three:&lt;/strong&gt; know which layer owns which fix. Downgrading infrastructure to dodge an application-level bug is a trap that's easy to fall into when you &lt;em&gt;can&lt;/em&gt; fix it from the Dockerfile but "can" and "should" aren't the same question. Isolate the patch, keep it visible, and hand the real fix to whoever owns that code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scan that failed for a reason that had nothing to do with any of this
&lt;/h3&gt;

&lt;p&gt;With the build green, Trivy failed the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total: 4 (HIGH: 4, CRITICAL: 0)
c-ares    CVE-2026-33630
libexpat  CVE-2026-56131 / 56407 / 56408
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these were application dependencies they were OS packages baked into the &lt;code&gt;nginx:alpine&lt;/code&gt; base image, stale relative to Alpine's own security advisories because the base image tag hadn't been rebuilt recently. The fix was a single line at the top of the runner stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apk update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apk upgrade &lt;span class="nt"&gt;--no-cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson four:&lt;/strong&gt; a clean base image today doesn't stay clean. Container security scanning isn't just about the app it's about everything shipped inside the image, and base image staleness is one of the most common, least glamorous sources of CVE noise in a pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: The Backend Nobody Had Documented
&lt;/h2&gt;

&lt;p&gt;The Django API had never had a real production Dockerfile just a dev one, running &lt;code&gt;manage.py runserver&lt;/code&gt; directly against production traffic, with a &lt;code&gt;docker/prod/&lt;/code&gt; directory that &lt;em&gt;looked&lt;/em&gt; complete but had never actually been wired up.&lt;/p&gt;

&lt;p&gt;Before writing anything, I had to reconstruct the actual setup by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; starsight-api-api-1 python &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# 3.9.25&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;docker/dev/Dockerfile                                &lt;span class="c"&gt;# base image, deps&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;docker/dev/entrypoint.sh                              &lt;span class="c"&gt;# startup logic&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;app/requirements/&lt;span class="k"&gt;*&lt;/span&gt;.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; gunicorn              &lt;span class="c"&gt;# already installed, unused&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"STATIC_ROOT"&lt;/span&gt; app/core/settings&lt;span class="k"&gt;*&lt;/span&gt;.py               &lt;span class="c"&gt;# static file config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is worth calling out on its own: &lt;strong&gt;the existing &lt;code&gt;docker/prod/entrypoint.sh&lt;/code&gt; had a bug that would have silently broken production had it ever shipped.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py migrate &lt;span class="nt"&gt;--no-input&lt;/span&gt;
python manage.py runserver 0.0.0.0:8000
python manage.py loaddata &lt;span class="k"&gt;*&lt;/span&gt;/fixtures/&lt;span class="k"&gt;*&lt;/span&gt;.json
&lt;span class="nb"&gt;rm &lt;/span&gt;celerybeat.pid
&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runserver&lt;/code&gt; is a blocking, foreground process it never returns. Every line after it, including &lt;code&gt;exec "$@"&lt;/code&gt; (which should hand off to the actual container command, i.e. Gunicorn), would never execute. &lt;code&gt;gunicorn==20.0.4&lt;/code&gt; was sitting in &lt;code&gt;prod.txt&lt;/code&gt;, fully installed, completely unused, because the entrypoint script never got past the dev server it was supposed to replace.&lt;/p&gt;

&lt;p&gt;There was also a syntax bug in the &lt;em&gt;other&lt;/em&gt; environment's entrypoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing a space after &lt;code&gt;if&lt;/code&gt; POSIX shell needs &lt;code&gt;[ "$DATABASE" = "postgres" ]&lt;/code&gt;, since &lt;code&gt;[&lt;/code&gt; is itself a command. As written, this throws &lt;code&gt;not found&lt;/code&gt; and silently skips the entire wait-for-database loop, meaning the app could start racing against a Postgres container that wasn't ready yet, with no visible error.&lt;/p&gt;

&lt;p&gt;Neither of these bugs had ever caused a visible incident, because neither entrypoint had ever actually been exercised in a real production deploy. That's the uncomfortable part: &lt;strong&gt;untested infrastructure code doesn't fail loudly, it just sits there until the day it does.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Building it properly
&lt;/h3&gt;

&lt;p&gt;The corrected entrypoint dropped the dev-only fixture loading and the blocking &lt;code&gt;runserver&lt;/code&gt; call entirely, fixed the shell syntax, and left exactly one job wait for the database, apply already-committed migrations, then hand off to whatever the container's real command is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SQL_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SQL_PORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;0.1
  &lt;span class="k"&gt;done
fi

&lt;/span&gt;python manage.py migrate &lt;span class="nt"&gt;--no-input&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; there: &lt;code&gt;makemigrations&lt;/code&gt;. Auto-generating migrations at deploy time is a trap migrations should be written and reviewed in development, committed to the repo, and production should only ever apply what's already there. An entrypoint that can generate schema changes on the fly is an entrypoint that can silently diverge from what a reviewer actually approved.&lt;/p&gt;

&lt;p&gt;The Dockerfile itself went through the same evolution as the frontend's first two stages (builder compiles wheels for native extensions like &lt;code&gt;psycopg2&lt;/code&gt; and &lt;code&gt;Pillow&lt;/code&gt;, runtime installs only the shared libs those wheels actually need at runtime), then a third &lt;code&gt;base&lt;/code&gt; stage once it became clear the &lt;code&gt;ENV&lt;/code&gt;/&lt;code&gt;WORKDIR&lt;/code&gt; lines were duplicated across both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.9-slim-bookworm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="c"&gt;# compiles wheels, discarded after build&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="c"&gt;# copies wheels, runs as non-root, Gunicorn as the actual process&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is worth being precise about: it's not a "GitOps best practice"  GitOps is about Git as the source of truth for declarative infrastructure state, completely orthogonal to Dockerfile stage count. It's just good multi-stage design: a shared base stage means &lt;code&gt;PYTHONUNBUFFERED&lt;/code&gt; or the workdir path only needs to change in one place, and both &lt;code&gt;builder&lt;/code&gt; and &lt;code&gt;runtime&lt;/code&gt; inherit it automatically instead of two copies that can silently drift out of sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that almost got skipped: the reverse proxy nobody remembered
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker/prod/&lt;/code&gt; also contained a full &lt;code&gt;jwilder/nginx-proxy&lt;/code&gt; setup its own Dockerfile, an &lt;code&gt;nginx.conf&lt;/code&gt;, a &lt;code&gt;vhost.d/default&lt;/code&gt; referencing static/media paths under &lt;code&gt;/home/app/web/&lt;/code&gt;. None of those paths matched this project's actual &lt;code&gt;STATIC_ROOT&lt;/code&gt; convention. That mismatch was the tell: this wasn't a working, tested config, it was a leftover from a boilerplate template, predating the team's move to Nginx Proxy Manager for reverse proxying everything externally. I excluded it entirely rather than trying to make broken paths correct the app just needed to expose a port for NPM to point at, same pattern as every other service on that droplet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Protecting the thing that actually mattered
&lt;/h3&gt;

&lt;p&gt;The Postgres container behind this API had 46 hours of live data by the time I got to it. The single most important constraint on the entire compose rewrite wasn't performance or elegance, it was: &lt;strong&gt;don't touch that volume.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;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:12.1-alpine&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;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker Compose derives a named volume's actual identity from the project directory, not the compose filename so switching from &lt;code&gt;docker-compose.yml&lt;/code&gt; to &lt;code&gt;docker-compose.prod.yml&lt;/code&gt; in the same directory resolves to the same underlying volume automatically. I verified this explicitly rather than assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;postgres_data
&lt;span class="c"&gt;# starsight-api_postgres_data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy step in CI also deliberately avoids a full &lt;code&gt;docker compose down&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; api celery celery-beat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-deps&lt;/code&gt; means only the application containers get recreated on every push. &lt;code&gt;db&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt; are never touched by the automated pipeline at all. An automated deploy that can accidentally tear down stateful infrastructure on every merge is a liability waiting for a bad day, better to make that structurally impossible than to rely on remembering not to add a flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Mattered Here
&lt;/h2&gt;

&lt;p&gt;None of these bugs were individually hard. A missing space in a shell conditional. A blocking dev server call. A Node ABI mismatch nobody would notice until runtime. A copy-pasted Dockerfile section from the wrong framework. What made this genuinely tricky was that &lt;strong&gt;almost none of it was visible until you went looking&lt;/strong&gt; the existing &lt;code&gt;docker/prod&lt;/code&gt; directory looked complete from a file listing. It had a Dockerfile, an entrypoint, an nginx setup. It just didn't work, and nothing had ever forced it to run.&lt;/p&gt;

&lt;p&gt;The actual work of this pipeline rebuild wasn't writing Dockerfiles, it was reconstructing what was true, one &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;docker exec&lt;/code&gt; at a time, before writing a single line of infrastructure code. Every fix downstream of that was straightforward. Getting to a Dockerfile you can actually trust starts with refusing to guess at what's already there.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>python</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Whether you're debugging, testing a new implementation, or temporarily disabling code, there are times when you need to comment out an entire file in Vim. While many developers rely on plugins, Vim already provides powerful built-in commands that make this incredibly fast.</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Sun, 12 Jul 2026 16:25:50 +0000</pubDate>
      <link>https://dev.to/saint_vandora/-2jpi</link>
      <guid>https://dev.to/saint_vandora/-2jpi</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78" class="crayons-story__hidden-navigation-link"&gt;Comment an Entire File in Vim in Seconds (Two Methods Every Developer Should Know)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/saint_vandora" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" alt="saint_vandora profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/saint_vandora" class="crayons-story__secondary fw-medium m:hidden"&gt;
              FOLASAYO SAMUEL OLAYEMI
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                FOLASAYO SAMUEL OLAYEMI
                
              
              &lt;div id="story-author-preview-content-4126843" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/saint_vandora" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;FOLASAYO SAMUEL OLAYEMI&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 12&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78" id="article-link-4126843"&gt;
          Comment an Entire File in Vim in Seconds (Two Methods Every Developer Should Know)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            2 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>cli</category>
      <category>productivity</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Comment an Entire File in Vim in Seconds (Two Methods Every Developer Should Know)</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Sun, 12 Jul 2026 16:09:09 +0000</pubDate>
      <link>https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78</link>
      <guid>https://dev.to/saint_vandora/comment-an-entire-file-in-vim-in-seconds-two-methods-every-developer-should-know-k78</guid>
      <description>&lt;p&gt;Whether you're debugging, testing a new implementation, or temporarily disabling code, there are times when you need to comment out an entire file in Vim. While many developers rely on plugins, Vim already provides powerful built-in commands that make this incredibly fast.&lt;/p&gt;

&lt;p&gt;Here are two efficient approaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: Highlight &amp;amp; Command Mode (Recommended)
&lt;/h2&gt;

&lt;p&gt;This approach is straightforward because you can visually confirm that the entire file is selected before applying the comment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Enter Normal Mode
&lt;/h3&gt;

&lt;p&gt;Press:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Esc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Select the Entire File
&lt;/h3&gt;

&lt;p&gt;Type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;ggVG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what each command does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gg&lt;/code&gt; → Jump to the beginning of the file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;V&lt;/code&gt; → Enter Visual Line Mode.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;G&lt;/code&gt; → Extend the selection to the last line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point, the entire file should be highlighted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add the Comment Prefix
&lt;/h3&gt;

&lt;p&gt;Press:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vim automatically inserts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;,'&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means "apply the following command to the selected lines."&lt;/p&gt;

&lt;p&gt;Now complete the command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;,'&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;s&lt;span class="sr"&gt;/^/&lt;/span&gt;# /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press &lt;strong&gt;Enter&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every line in the file now begins with &lt;code&gt;#&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use the Appropriate Comment Symbol
&lt;/h3&gt;

&lt;p&gt;Replace &lt;code&gt;#&lt;/code&gt; with the correct comment syntax for your language.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Comment Prefix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;&lt;code&gt;//&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;&lt;code&gt;//&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C/C++&lt;/td&gt;
&lt;td&gt;&lt;code&gt;//&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;&lt;code&gt;//&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;&lt;code&gt;//&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;&lt;code&gt;//&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lua&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, to comment every JavaScript line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;,'&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;s&lt;span class="sr"&gt;/^/&lt;/span&gt;\&lt;span class="sr"&gt;/\/ /&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Method 2: Visual Block Mode
&lt;/h2&gt;

&lt;p&gt;This method leverages Vim's powerful column-editing capability to insert text at the beginning of multiple lines simultaneously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Go to the Beginning
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;gg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Start Visual Block Mode
&lt;/h3&gt;

&lt;p&gt;Press:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ctrl + V
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Select Every Line
&lt;/h3&gt;

&lt;p&gt;Press:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Shift + G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The block selection extends to the end of the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Insert the Comment Character
&lt;/h3&gt;

&lt;p&gt;Press:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Shift + I
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This enters Insert Mode at the start of the selected block.&lt;/p&gt;

&lt;p&gt;Now type your comment prefix, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;//
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Apply It Everywhere
&lt;/h3&gt;

&lt;p&gt;Press:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Esc
Esc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the second &lt;strong&gt;Esc&lt;/strong&gt;, Vim inserts the comment prefix on every selected line simultaneously.&lt;/p&gt;

&lt;p&gt;It's one of those Vim features that feels like magic the first time you see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uncomment Everything
&lt;/h2&gt;

&lt;p&gt;Need to restore the file?&lt;/p&gt;

&lt;p&gt;If every line starts with &lt;code&gt;#&lt;/code&gt;, simply run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;%s&lt;span class="sr"&gt;/^# /&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This searches every line (&lt;code&gt;%&lt;/code&gt;) for &lt;code&gt;#&lt;/code&gt; at the beginning (&lt;code&gt;^&lt;/code&gt;) and removes it.&lt;/p&gt;

&lt;p&gt;For JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;%s&lt;span class="sr"&gt;/^\/\/ /&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Lua:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight viml"&gt;&lt;code&gt;&lt;span class="p"&gt;:&lt;/span&gt;%s&lt;span class="sr"&gt;/^-- /&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same principle works for any comment prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Method Should You Use?
&lt;/h2&gt;

&lt;p&gt;Both methods are useful, but each has its strengths.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Highlight &amp;amp; Command Mode&lt;/strong&gt; is easy to understand, visually confirms your selection, and is ideal for search-and-replace operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual Block Mode&lt;/strong&gt; is faster once you're comfortable with Vim and is perfect for inserting text at the same column across multiple lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're just getting started with Vim, I recommend learning &lt;strong&gt;Method 1&lt;/strong&gt; first. Once you're comfortable with Visual Block Mode, you'll find yourself using it for much more than commenting, it becomes invaluable for editing structured text, logs, configuration files, and source code.&lt;/p&gt;

&lt;p&gt;Mastering these native Vim techniques means you can work efficiently without relying on plugins, making your editing experience faster and more portable across environments.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;br&gt;
&lt;strong&gt;Happy Coding!&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Deploying a Containerized Backend to a VPS with Docker Compose + GitHub Actions (A Beginner's Runbook)</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Thu, 25 Jun 2026 18:24:48 +0000</pubDate>
      <link>https://dev.to/saint_vandora/deploying-a-containerized-backend-to-a-vps-with-docker-compose-github-actions-a-beginners-39m5</link>
      <guid>https://dev.to/saint_vandora/deploying-a-containerized-backend-to-a-vps-with-docker-compose-github-actions-a-beginners-39m5</guid>
      <description>&lt;p&gt;This is a complete, copy‑pasteable guide for shipping a backend app to a single Linux server using &lt;strong&gt;Docker Compose&lt;/strong&gt;, with a &lt;strong&gt;GitHub Actions&lt;/strong&gt; pipeline that builds the image, scans it, and deploys it over SSH.&lt;/p&gt;

&lt;p&gt;It is written to be &lt;strong&gt;language- and framework-agnostic&lt;/strong&gt;. The examples use a Node/TypeScript API with PostgreSQL, Redis, and a background worker, but the same shape works for Python/Django, Go, Java/Spring, Ruby, etc. Anywhere you see &lt;code&gt;your-app&lt;/code&gt;, &lt;code&gt;your-org&lt;/code&gt;, &lt;code&gt;your-server-ip&lt;/code&gt;, or &lt;code&gt;example.com&lt;/code&gt;, substitute your own values.&lt;/p&gt;

&lt;p&gt;Every file is included in full, and every non-obvious line is explained. The last section — &lt;strong&gt;Common errors and how to fix them&lt;/strong&gt; — is the part most guides skip, and it is the part that will actually save your afternoon. All of it comes from a real deployment, mistakes included.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The mental model (read this first)
&lt;/h2&gt;

&lt;p&gt;Before any YAML, understand the shape of what we're building. There are only three places anything lives:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your Git repository&lt;/strong&gt; the single source of truth. Your code, your &lt;code&gt;Dockerfile&lt;/code&gt;, your &lt;code&gt;docker-compose.prod.yml&lt;/code&gt;, and your CI/CD workflows all live here. &lt;em&gt;You only ever edit things here.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A container registry&lt;/strong&gt; (we use GHCR, GitHub's built-in registry) — a warehouse for the built application image. CI builds the image and pushes it here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your server&lt;/strong&gt; (a plain Linux VPS) pulls the image from the registry and runs it. It holds exactly two files: the compose file (copied from your repo by the pipeline) and a secrets file (&lt;code&gt;.env&lt;/code&gt;) that never leaves the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flow, end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You push to main
      │
      ▼
GitHub Actions: build image ──► push to registry ──► scan image
      │
      ▼
GitHub Actions: SSH to server ──► pull image ──► run migrations ──► start app ──► health-check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The single most important rule:&lt;/strong&gt; the server is &lt;em&gt;disposable&lt;/em&gt;. You never hand-edit files on the server, because the pipeline overwrites them from the repo on every deploy. If you fix something by editing on the server, the next deploy silently erases your fix. Edit in the repo, commit, push. (I learned this one the hard way see the errors section.)&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Architecture of the running stack
&lt;/h2&gt;

&lt;p&gt;On the server, Docker Compose runs several containers on a private network. Only one port is exposed to the outside world, and even that only on loopback (a reverse proxy / ingress handles TLS in front).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Container&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Exposed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The database&lt;/td&gt;
&lt;td&gt;No — internal only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pgbouncer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A connection pooler in front of Postgres&lt;/td&gt;
&lt;td&gt;No — internal only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cache / job queue / session store&lt;/td&gt;
&lt;td&gt;No — internal only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;migrate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;one-shot&lt;/strong&gt; container: runs DB migrations, then exits&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your web API process&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background job processor (same image as api)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two ideas worth internalizing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One image, two roles.&lt;/strong&gt; The &lt;code&gt;api&lt;/code&gt; and &lt;code&gt;worker&lt;/code&gt; are the &lt;em&gt;same&lt;/em&gt; built image. They differ only by the command they run. This keeps builds simple and guarantees the API and worker are always the same version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boot order matters.&lt;/strong&gt; Containers must start in dependency order, or you get race conditions: &lt;code&gt;postgres&lt;/code&gt; becomes healthy → &lt;code&gt;pgbouncer&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt; become healthy → &lt;code&gt;migrate&lt;/code&gt; runs and exits cleanly → only then do &lt;code&gt;api&lt;/code&gt; and &lt;code&gt;worker&lt;/code&gt; start. Compose enforces this with &lt;code&gt;depends_on&lt;/code&gt; + health conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Dockerfile
&lt;/h2&gt;

&lt;p&gt;This is a &lt;strong&gt;multi-stage&lt;/strong&gt; build. Each &lt;code&gt;FROM&lt;/code&gt; starts a new stage; only the final stage becomes your shipped image. The point of multi-stage is that build tools (compilers, dev dependencies) stay out of the final image, making it smaller and safer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;

&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="c"&gt;# Base — package manager + workdir, pinned for reproducibility&lt;/span&gt;
&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; libc6-compat
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; corepack@latest &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; corepack &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; corepack prepare pnpm@10.16.1 &lt;span class="nt"&gt;--activate&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="c"&gt;# 1. Dependencies (including dev deps — needed to build)&lt;/span&gt;
&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json pnpm-lock.yaml* ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--frozen-lockfile&lt;/span&gt;

&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="c"&gt;# 2. Build — compile source to /dist&lt;/span&gt;
&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pnpm build

&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="c"&gt;# 3. Production dependencies only (no dev deps)&lt;/span&gt;
&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;prod-deps&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json pnpm-lock.yaml* ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--frozen-lockfile&lt;/span&gt;

&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="c"&gt;# 4. Runner — the final, minimal image&lt;/span&gt;
&lt;span class="c"&gt;# =========================================================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="c"&gt;# tini = correct PID 1 / signal handling; wget = used by container healthchecks.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; libc6-compat wget tini

&lt;span class="c"&gt;# Remove package managers from the runtime image. Migrations call the migration&lt;/span&gt;
&lt;span class="c"&gt;# CLI via `node` directly, so npm/pnpm aren't needed at runtime and removing&lt;/span&gt;
&lt;span class="c"&gt;# them shrinks the attack surface (image scanners flag their bundled CVEs).&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx &lt;span class="se"&gt;\
&lt;/span&gt;    /usr/local/bin/corepack /usr/local/lib/node_modules/corepack &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PORT=4000&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; WORKER_PORT=4001&lt;/span&gt;

&lt;span class="c"&gt;# Run as a NON-root user. Never run app containers as root.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; 1001 nodejs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 1001 appuser

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=prod-deps --chown=appuser:nodejs /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build     --chown=appuser:nodejs /app/dist         ./dist&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=appuser:nodejs package.json ./&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; appuser&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 4000 4001&lt;/span&gt;

&lt;span class="c"&gt;# tini is the entrypoint so signals (Ctrl-C, container stop) are handled properly.&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/sbin/tini", "--"]&lt;/span&gt;
&lt;span class="c"&gt;# Default command = API. The worker overrides this in the compose file.&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/main"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why each stage exists&lt;/strong&gt;, in plain terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;base&lt;/strong&gt;: shared starting point the language runtime and package manager, pinned to exact versions so builds are reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deps&lt;/strong&gt;: installs &lt;em&gt;all&lt;/em&gt; dependencies (including dev tools) because you need them to compile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;build&lt;/strong&gt;: compiles your source into a &lt;code&gt;dist/&lt;/code&gt; folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;prod-deps&lt;/strong&gt;: installs &lt;em&gt;only&lt;/em&gt; production dependencies into a clean folder — this is what ships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runner&lt;/strong&gt;: the final image. It copies in the compiled &lt;code&gt;dist/&lt;/code&gt; and the production-only &lt;code&gt;node_modules&lt;/code&gt;, runs as a non-root user, and deliberately &lt;em&gt;removes&lt;/em&gt; package managers to reduce CVEs.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Adapting to other stacks:&lt;/strong&gt; Python would &lt;code&gt;pip install&lt;/code&gt; into a venv in a build stage and copy the venv into a slim runtime; Go would compile a static binary in a build stage and copy just the binary into a &lt;code&gt;scratch&lt;/code&gt;/&lt;code&gt;distroless&lt;/code&gt; image. The pattern is identical: build fat, ship thin, run as non-root.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A small but important detail: the runtime image keeps &lt;code&gt;wget&lt;/code&gt; because the container's own &lt;strong&gt;healthcheck&lt;/strong&gt; uses it. If you strip it out, your healthchecks silently break.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. docker-compose.prod.yml the whole stack in one file
&lt;/h2&gt;

&lt;p&gt;This is the file that runs on the server. It is &lt;strong&gt;self-contained&lt;/strong&gt;: the only other file it needs is &lt;code&gt;.env&lt;/code&gt;. No source code on the server, no separate init scripts everything is inlined.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Requires Docker Compose &lt;strong&gt;v2.23.1+&lt;/strong&gt; (for the inline &lt;code&gt;configs.content&lt;/code&gt; feature used below). Check with &lt;code&gt;docker compose version&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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;your-app&lt;/span&gt;

&lt;span class="c1"&gt;# Shared application environment. Secrets are interpolated from .env.&lt;/span&gt;
&lt;span class="c1"&gt;# Defining them once here and reusing via a YAML anchor avoids copy-paste drift.&lt;/span&gt;
&lt;span class="na"&gt;x-app-env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;app-env&lt;/span&gt;
  &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4000"&lt;/span&gt;
  &lt;span class="na"&gt;WORKER_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4001"&lt;/span&gt;
  &lt;span class="c1"&gt;# The app connects through pgbouncer; the migrator connects to postgres directly.&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://app_user:${APP_DB_PASSWORD}@pgbouncer:5432/appdb&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_MIGRATOR_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://migrator_user:${MIGRATOR_DB_PASSWORD}@postgres:5432/appdb&lt;/span&gt;
  &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379&lt;/span&gt;
  &lt;span class="na"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_SECRET}&lt;/span&gt;
  &lt;span class="na"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_ENDPOINT}&lt;/span&gt;
  &lt;span class="na"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_BUCKET}&lt;/span&gt;
  &lt;span class="na"&gt;S3_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_ACCESS_KEY}&lt;/span&gt;
  &lt;span class="na"&gt;S3_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_SECRET_KEY}&lt;/span&gt;
  &lt;span class="na"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${LOG_LEVEL:-info}&lt;/span&gt;

&lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# The database init script, inlined. It runs ONCE, only when the postgres&lt;/span&gt;
  &lt;span class="c1"&gt;# data volume is first created (i.e. an empty database). Passwords are&lt;/span&gt;
  &lt;span class="c1"&gt;# interpolated from .env, so the committed compose file contains no secrets.&lt;/span&gt;
  &lt;span class="na"&gt;postgres_init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;CREATE ROLE app_user      WITH LOGIN PASSWORD '${APP_DB_PASSWORD}';&lt;/span&gt;
      &lt;span class="s"&gt;CREATE ROLE migrator_user WITH LOGIN PASSWORD '${MIGRATOR_DB_PASSWORD}';&lt;/span&gt;

      &lt;span class="s"&gt;-- Timeouts set at the ROLE level. Under pgbouncer transaction pooling,&lt;/span&gt;
      &lt;span class="s"&gt;-- per-session SETs don't reliably stick, so role-level is the safe place.&lt;/span&gt;
      &lt;span class="s"&gt;ALTER ROLE app_user SET statement_timeout = '15s';&lt;/span&gt;
      &lt;span class="s"&gt;ALTER ROLE app_user SET idle_in_transaction_session_timeout = '15s';&lt;/span&gt;

      &lt;span class="s"&gt;GRANT CONNECT ON DATABASE appdb TO app_user, migrator_user;&lt;/span&gt;

      &lt;span class="s"&gt;-- The migrator needs to create schemas, so it needs CREATE on the database.&lt;/span&gt;
      &lt;span class="s"&gt;-- Without this, the first migration fails: "permission denied for database".&lt;/span&gt;
      &lt;span class="s"&gt;GRANT CREATE ON DATABASE appdb TO migrator_user;&lt;/span&gt;

      &lt;span class="s"&gt;-- Many ORMs write a "migrations" bookkeeping table into a custom schema&lt;/span&gt;
      &lt;span class="s"&gt;-- BEFORE running the migration that would create that schema a chicken&lt;/span&gt;
      &lt;span class="s"&gt;-- and egg. Pre-create the schema here so the first run can't fail with&lt;/span&gt;
      &lt;span class="s"&gt;-- "schema ... does not exist". (Use the schema name YOUR app expects.)&lt;/span&gt;
      &lt;span class="s"&gt;CREATE SCHEMA IF NOT EXISTS platform AUTHORIZATION migrator_user;&lt;/span&gt;

      &lt;span class="s"&gt;GRANT USAGE, CREATE ON SCHEMA public TO migrator_user;&lt;/span&gt;
      &lt;span class="s"&gt;GRANT USAGE          ON SCHEMA public TO app_user;&lt;/span&gt;

      &lt;span class="s"&gt;-- Tables the migrator creates later should be usable by the app user.&lt;/span&gt;
      &lt;span class="s"&gt;ALTER DEFAULT PRIVILEGES FOR ROLE migrator_user IN SCHEMA public&lt;/span&gt;
        &lt;span class="s"&gt;GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;&lt;/span&gt;
      &lt;span class="s"&gt;ALTER DEFAULT PRIVILEGES FOR ROLE migrator_user IN SCHEMA public&lt;/span&gt;
        &lt;span class="s"&gt;GRANT USAGE, SELECT ON SEQUENCES TO app_user;&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&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:16&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&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;postgres&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;${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}&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;appdb&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;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres_init&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/docker-entrypoint-initdb.d/01-init.sql&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;appdb'&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;5s&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;5s&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;10&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# NOT published — the database must never be reachable from the internet.&lt;/span&gt;

  &lt;span class="na"&gt;pgbouncer&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;edoburu/pgbouncer: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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appdb&lt;/span&gt;
      &lt;span class="na"&gt;DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user&lt;/span&gt;
      &lt;span class="na"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_DB_PASSWORD:?set APP_DB_PASSWORD in .env}&lt;/span&gt;
      &lt;span class="na"&gt;AUTH_TYPE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scram-sha-256&lt;/span&gt;
      &lt;span class="na"&gt;POOL_MODE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;
      &lt;span class="na"&gt;MAX_CLIENT_CONN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
      &lt;span class="na"&gt;DEFAULT_POOL_SIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
      &lt;span class="c1"&gt;# DB drivers send these as connection "startup parameters". In transaction&lt;/span&gt;
      &lt;span class="c1"&gt;# pooling mode pgbouncer rejects unknown ones with "unsupported startup&lt;/span&gt;
      &lt;span class="c1"&gt;# parameter". List the ones your driver sends so pgbouncer tolerates them.&lt;/span&gt;
      &lt;span class="na"&gt;IGNORE_STARTUP_PARAMETERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;extra_float_digits,statement_timeout,lock_timeout,idle_in_transaction_session_timeout&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_isready'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-h'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-p'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5432'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-U'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app_user'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-d'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;appdb'&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;5s&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;3s&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;10&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;redis&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-alpine&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="c1"&gt;# noeviction: this Redis holds real state (jobs, sessions), not just cache,&lt;/span&gt;
    &lt;span class="c1"&gt;# so fail loudly rather than silently dropping keys. AOF persists to disk.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;redis-server'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--maxmemory'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;256mb'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--maxmemory-policy'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;noeviction'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--appendonly'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;yes'&lt;/span&gt;&lt;span class="pi"&gt;]&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;redis_data:/data&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;redis-cli'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ping'&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;5s&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;3s&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;10&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# One-shot migrations. Must exit 0 before api/worker start.&lt;/span&gt;
  &lt;span class="na"&gt;migrate&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;${BACKEND_IMAGE:-ghcr.io/your-org/your-app:latest}&lt;/span&gt;
    &lt;span class="na"&gt;init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no'&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dist/migrate'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# however YOUR app runs migrations&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;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*app-env&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;api&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;${BACKEND_IMAGE:-ghcr.io/your-org/your-app:latest}&lt;/span&gt;
    &lt;span class="na"&gt;init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dist/main'&lt;/span&gt;&lt;span class="pi"&gt;]&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;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*app-env&lt;/span&gt;
      &lt;span class="na"&gt;PROCESS_ROLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:4000:4000'&lt;/span&gt;   &lt;span class="c1"&gt;# loopback only; reverse proxy sits in front&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
      &lt;span class="na"&gt;pgbouncer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wget'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-qO-'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://localhost:4000/api/health'&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;15s&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;5s&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;10&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;   &lt;span class="c1"&gt;# grace period for cold start before failures count&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;worker&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;${BACKEND_IMAGE:-ghcr.io/your-org/your-app:latest}&lt;/span&gt;
    &lt;span class="na"&gt;init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dist/worker'&lt;/span&gt;&lt;span class="pi"&gt;]&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;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*app-env&lt;/span&gt;
      &lt;span class="na"&gt;PROCESS_ROLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:4001:4001'&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
      &lt;span class="na"&gt;pgbouncer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wget'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-qO-'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://localhost:4001/health'&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;15s&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;5s&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;10&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;]&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;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The parts that trip people up, explained
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;name: your-app&lt;/code&gt;&lt;/strong&gt; this is the Compose &lt;em&gt;project name&lt;/em&gt;. It is not cosmetic: Compose prefixes your volume names with it (e.g. &lt;code&gt;your-app_postgres_data&lt;/code&gt;). &lt;strong&gt;If you change this name, Compose looks for differently-named volumes and your database appears to vanish&lt;/strong&gt; it's still on disk under the old name, but the stack now points at a new, empty volume. &lt;strong&gt;Pin this and never change it.&lt;/strong&gt; This is the single most dangerous footgun in the whole file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;x-app-env: &amp;amp;app-env&lt;/code&gt;&lt;/strong&gt; the &lt;code&gt;&amp;amp;app-env&lt;/code&gt; defines a YAML &lt;em&gt;anchor&lt;/em&gt; (a reusable block). Each service then writes &lt;code&gt;&amp;lt;&amp;lt;: *app-env&lt;/code&gt; to merge that block in (&lt;code&gt;*app-env&lt;/code&gt; is a &lt;em&gt;reference&lt;/em&gt; to the anchor). This is why all three app containers share identical env without copy-paste. &lt;strong&gt;If you delete the anchor line but leave the &lt;code&gt;*app-env&lt;/code&gt; references, the file won't parse&lt;/strong&gt; the references point at nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;${VAR:?error message}&lt;/code&gt;&lt;/strong&gt; fail fast. If &lt;code&gt;VAR&lt;/code&gt; isn't set in &lt;code&gt;.env&lt;/code&gt;, Compose refuses to start with your message instead of booting with a broken config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;${VAR:-default}&lt;/code&gt;&lt;/strong&gt; use &lt;code&gt;default&lt;/code&gt; if &lt;code&gt;VAR&lt;/code&gt; isn't set. Good for optional tuning values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;configs:&lt;/code&gt; with inline &lt;code&gt;content:&lt;/code&gt;&lt;/strong&gt; lets you ship the database init SQL &lt;em&gt;inside&lt;/em&gt; the compose file, with no separate file to copy. It's mounted into Postgres's &lt;code&gt;docker-entrypoint-initdb.d/&lt;/code&gt;, which Postgres runs &lt;strong&gt;only on first boot of an empty data volume&lt;/strong&gt;. Remember that last part see the migration error below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition:&lt;/code&gt;&lt;/strong&gt; this is what gives you correct boot order. &lt;code&gt;service_healthy&lt;/code&gt; waits for a container's healthcheck to pass; &lt;code&gt;service_completed_successfully&lt;/code&gt; waits for the one-shot &lt;code&gt;migrate&lt;/code&gt; to exit 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;start_period: 120s&lt;/code&gt;&lt;/strong&gt; on healthchecks during this window, failing health probes don't count against the container. Apps that map hundreds of routes or warm caches can take a while; without a grace period the orchestrator declares them dead before they finish booting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why pgbouncer at all?&lt;/strong&gt; A connection pooler sits between your app and Postgres so that many short app connections share a small number of real database connections. It dramatically reduces DB load. The catch is &lt;strong&gt;transaction pooling mode&lt;/strong&gt; is stricter about connection "startup parameters" hence &lt;code&gt;IGNORE_STARTUP_PARAMETERS&lt;/code&gt; (more in the errors section).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The secrets file: .env.example
&lt;/h2&gt;

&lt;p&gt;Commit &lt;code&gt;.env.example&lt;/code&gt; (a template with empty values). The real &lt;code&gt;.env&lt;/code&gt; is created &lt;strong&gt;on the server by hand, once&lt;/strong&gt;, and is &lt;strong&gt;never committed&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Copy to ".env" (literal name) next to docker-compose.prod.yml ON THE SERVER.&lt;/span&gt;
&lt;span class="c"&gt;# docker compose reads it automatically for ${...} interpolation.&lt;/span&gt;
&lt;span class="c"&gt;# NEVER commit the real .env.&lt;/span&gt;

&lt;span class="c"&gt;# --- Secrets (generate once; store in a password manager) ------------------&lt;/span&gt;
&lt;span class="c"&gt;# IMPORTANT: these values go INTO connection URLs, so use URL-SAFE values.&lt;/span&gt;
&lt;span class="c"&gt;# `openssl rand -base64` can emit + / = which break URL parsing — prefer hex:&lt;/span&gt;
&lt;span class="c"&gt;#   openssl rand -hex 32&lt;/span&gt;
&lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;APP_DB_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;MIGRATOR_DB_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;                 &lt;span class="c"&gt;# at least 32 characters&lt;/span&gt;

&lt;span class="c"&gt;# --- External object storage (S3-compatible) -------------------------------&lt;/span&gt;
&lt;span class="nv"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;S3_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;S3_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;S3_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# --- Optional overrides (sensible defaults applied in compose) -------------&lt;/span&gt;
&lt;span class="c"&gt;# LOG_LEVEL=info&lt;/span&gt;

&lt;span class="c"&gt;# --- Image (the deploy workflow sets this automatically; only set to pin) --&lt;/span&gt;
&lt;span class="c"&gt;# BACKEND_IMAGE=ghcr.io/your-org/your-app:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Generate passwords with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;, not &lt;code&gt;-base64&lt;/code&gt;.&lt;/strong&gt; Base64 output can contain &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, and &lt;code&gt;=&lt;/code&gt;, which break when embedded in a &lt;code&gt;postgresql://user:password@host/db&lt;/code&gt; URL. Hex is always URL-safe. This is a genuinely sneaky bug the password "looks fine" but the connection string is silently malformed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. CI part 1: code-quality.yml (runs first, on every push)
&lt;/h2&gt;

&lt;p&gt;This workflow runs static analysis / a quality gate. The deploy workflow only triggers if this one &lt;strong&gt;succeeds&lt;/strong&gt;, so it acts as a gate. (Swap SonarQube for whatever you use — ESLint, CodeQL, etc.)&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;CodeQuality Checks&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="s"&gt;main&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;code-quality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;   &lt;span class="c1"&gt;# full history; some scanners need it for blame/new-code&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;Static analysis scan&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonarsource/sonarqube-scan-action@v5&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;SONAR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;SONAR_HOST_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_HOST_URL }}&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;Quality Gate&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonarsource/sonarqube-quality-gate-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;timeout-minutes&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;SONAR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_TOKEN }}&lt;/span&gt;
          &lt;span class="c1"&gt;# For a SELF-HOSTED scanner you MUST pass the host URL here too, or the&lt;/span&gt;
          &lt;span class="c1"&gt;# gate action defaults to the cloud service, can't find your project,&lt;/span&gt;
          &lt;span class="c1"&gt;# and fails with a confusing HTTP 404.&lt;/span&gt;
          &lt;span class="na"&gt;SONAR_HOST_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SONAR_HOST_URL }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing worth highlighting: a self-hosted quality scanner needs its &lt;code&gt;SONAR_HOST_URL&lt;/code&gt; on &lt;strong&gt;both&lt;/strong&gt; the scan step and the gate step. Miss it on the gate step and you get a 404 that looks like a credentials problem but isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. CI part 2: main.yml (build, scan, deploy)
&lt;/h2&gt;

&lt;p&gt;This is the workhorse. It triggers &lt;strong&gt;after&lt;/strong&gt; the quality workflow completes, and runs four jobs in sequence: dependency audit → build &amp;amp; push image → scan image → deploy.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&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;CodeQuality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Checks"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# only runs after the quality workflow&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;REGISTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# never interrupt an in-flight deploy&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# 1) Block the deploy if a production dependency has a known high-severity CVE&lt;/span&gt;
  &lt;span class="na"&gt;dependency-check&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;Dependency Vulnerability Check&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;${{ github.event.workflow_run.conclusion == 'success' }}&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm/action-setup@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pnpm'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm install --frozen-lockfile&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;Audit production dependencies (blocking)&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm audit --prod --audit-level=high&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;Audit everything (report only)&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm audit --audit-level=high&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="c1"&gt;# 2) Build the image once, push to the registry&lt;/span&gt;
  &lt;span class="na"&gt;build-and-push&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;Build &amp;amp; Push Image&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dependency-check&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# needed to push to GHCR&lt;/span&gt;
    &lt;span class="na"&gt;outputs&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;${{ steps.image-name.outputs.image }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&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;Compute lowercase image name&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-name&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v4&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;Log in to registry&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&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;Image metadata (tags)&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/metadata-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.image-name.outputs.image }}&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;type=sha,prefix=sha-&lt;/span&gt;
            &lt;span class="s"&gt;type=raw,value=latest,enable={{is_default_branch}}&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;Build and push&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./Dockerfile&lt;/span&gt;
          &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runner&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.tags }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.labels }}&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;

  &lt;span class="c1"&gt;# 3) Scan the built image for OS/package CVEs; fail on CRITICAL/HIGH&lt;/span&gt;
  &lt;span class="na"&gt;image-scan&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;Container Security Scan&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-push&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;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;Log in to registry&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&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;Trivy scan&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25&lt;/span&gt; &lt;span class="c1"&gt;# pin actions by SHA&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build-and-push.outputs.image }}:latest&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CRITICAL,HIGH&lt;/span&gt;
          &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
          &lt;span class="na"&gt;ignore-unfixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# don't fail on CVEs with no fix available yet&lt;/span&gt;

  &lt;span class="c1"&gt;# 4) Deploy: copy compose to server, pull image, migrate, start, health-check&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Server&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;image-scan&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&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;Ensure deploy directory exists&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_IP }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mkdir -p "${{ secrets.DEPLOY_PATH }}"&lt;/span&gt;

      &lt;span class="c1"&gt;# The compose file is the source of truth in git and is shipped to the&lt;/span&gt;
      &lt;span class="c1"&gt;# server EVERY deploy (overwrite: true), so the server can never drift.&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;Copy compose file to server&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/scp-action@v0.1.7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_IP }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose.prod.yml&lt;/span&gt;
          &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEPLOY_PATH }}&lt;/span&gt;
          &lt;span class="na"&gt;overwrite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy over SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&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;GHCR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GHCR_TOKEN }}&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;${{ needs.build-and-push.outputs.image }}&lt;/span&gt;
          &lt;span class="na"&gt;DEPLOY_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEPLOY_PATH }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_IP }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;envs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GHCR_TOKEN,IMAGE,DEPLOY_PATH&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
            &lt;span class="s"&gt;echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin&lt;/span&gt;

            &lt;span class="s"&gt;cd "$DEPLOY_PATH"&lt;/span&gt;

            &lt;span class="s"&gt;# .env holds all secrets and is never in git — it must already exist.&lt;/span&gt;
            &lt;span class="s"&gt;if [ ! -f .env ]; then&lt;/span&gt;
              &lt;span class="s"&gt;echo "ERROR: $DEPLOY_PATH/.env is missing. Create it from .env.example first."&lt;/span&gt;
              &lt;span class="s"&gt;exit 1&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;

            &lt;span class="s"&gt;export BACKEND_IMAGE="${IMAGE}:latest"&lt;/span&gt;
            &lt;span class="s"&gt;docker pull "$BACKEND_IMAGE"&lt;/span&gt;

            &lt;span class="s"&gt;# No `down` named volumes are never touched, so zero data loss and&lt;/span&gt;
            &lt;span class="s"&gt;# no DB downtime. The one-shot migrate runs forward-only migrations&lt;/span&gt;
            &lt;span class="s"&gt;# and must exit 0; if it fails, `up` returns non-zero and we stop.&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f docker-compose.prod.yml up -d --remove-orphans&lt;/span&gt;

            &lt;span class="s"&gt;# Wait on the CONTAINER healthcheck (the single source of truth),&lt;/span&gt;
            &lt;span class="s"&gt;# not a separate host-side probe. 5-minute budget for cold starts.&lt;/span&gt;
            &lt;span class="s"&gt;echo "Waiting for services to become healthy (up to 5 min)..."&lt;/span&gt;
            &lt;span class="s"&gt;deadline=$((SECONDS + 300))&lt;/span&gt;
            &lt;span class="s"&gt;for svc in api worker; do&lt;/span&gt;
              &lt;span class="s"&gt;cid="$(docker compose -f docker-compose.prod.yml ps -q "$svc")"&lt;/span&gt;
              &lt;span class="s"&gt;if [ -z "$cid" ]; then&lt;/span&gt;
                &lt;span class="s"&gt;echo "ERROR: $svc container not created."; docker compose ps; exit 1&lt;/span&gt;
              &lt;span class="s"&gt;fi&lt;/span&gt;
              &lt;span class="s"&gt;while true; do&lt;/span&gt;
                &lt;span class="s"&gt;status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2&amp;gt;/dev/null || echo missing)"&lt;/span&gt;
                &lt;span class="s"&gt;case "$status" in&lt;/span&gt;
                  &lt;span class="s"&gt;healthy)   echo "$svc: healthy"; break ;;&lt;/span&gt;
                  &lt;span class="s"&gt;unhealthy) echo "ERROR: $svc unhealthy. Logs:"; docker compose -f docker-compose.prod.yml logs --tail=100 "$svc"; exit 1 ;;&lt;/span&gt;
                &lt;span class="s"&gt;esac&lt;/span&gt;
                &lt;span class="s"&gt;if [ "$SECONDS" -ge "$deadline" ]; then&lt;/span&gt;
                  &lt;span class="s"&gt;echo "ERROR: $svc not healthy in time. Logs:"; docker compose -f docker-compose.prod.yml logs --tail=100 "$svc"; exit 1&lt;/span&gt;
                &lt;span class="s"&gt;fi&lt;/span&gt;
                &lt;span class="s"&gt;sleep 5&lt;/span&gt;
              &lt;span class="s"&gt;done&lt;/span&gt;
            &lt;span class="s"&gt;done&lt;/span&gt;
            &lt;span class="s"&gt;echo "All services healthy."&lt;/span&gt;

            &lt;span class="s"&gt;# Prune only AFTER success, so the previous image stays for rollback.&lt;/span&gt;
            &lt;span class="s"&gt;docker image prune -f&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f docker-compose.prod.yml ps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why the deploy job is shaped this way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It triggers off the quality workflow&lt;/strong&gt; (&lt;code&gt;workflow_run&lt;/code&gt;), so a bad commit that fails quality never reaches the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image is built once&lt;/strong&gt; in CI and pushed to the registry. The server only &lt;em&gt;pulls&lt;/em&gt; it never builds. Builds are slow and resource-hungry; your small VPS shouldn't do them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions are pinned&lt;/strong&gt; third-party actions like Trivy are pinned to a commit SHA, not a moving tag, so a compromised release can't silently change what runs in your pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;docker compose down&lt;/code&gt;&lt;/strong&gt; bringing the stack &lt;code&gt;down&lt;/code&gt; can remove containers and (with &lt;code&gt;-v&lt;/code&gt;) volumes. We only ever &lt;code&gt;up -d&lt;/code&gt;, which recreates &lt;em&gt;changed&lt;/em&gt; containers and leaves the database volume untouched. Zero data-layer downtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The health gate waits on the container's own healthcheck&lt;/strong&gt; via &lt;code&gt;docker inspect&lt;/code&gt;, with a 5-minute budget. This is more reliable than a separate &lt;code&gt;curl&lt;/code&gt; from the host, because it uses the exact probe defined in compose and accounts for slow cold starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prune happens last&lt;/strong&gt; only after the new version is confirmed healthy, so the previous image is still around for a fast manual rollback if needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Keeping dependencies fresh: dependabot.yml
&lt;/h2&gt;

&lt;p&gt;Drop this in &lt;code&gt;.github/dependabot.yml&lt;/code&gt;. It opens grouped, scheduled PRs to bump dependencies, GitHub Actions versions, and your Docker base image.&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;updates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm"&lt;/span&gt;     &lt;span class="c1"&gt;# covers package-lock / pnpm-lock&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
    &lt;span class="na"&gt;schedule&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly"&lt;/span&gt;
      &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monday"&lt;/span&gt;
    &lt;span class="na"&gt;open-pull-requests-limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# group related bumps into ONE PR to review&lt;/span&gt;
      &lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;patterns&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;@nestjs/*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;dev-tooling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dependency-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;development"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github-actions"&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
    &lt;span class="na"&gt;schedule&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly"&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;patterns&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;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docker"&lt;/span&gt;   &lt;span class="c1"&gt;# bumps your Dockerfile base image&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
    &lt;span class="na"&gt;schedule&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Grouping&lt;/strong&gt; is the feature that makes Dependabot bearable: instead of twenty separate PRs, you get a handful of grouped ones you can review and merge together.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Step-by-step: your first deploy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One-time setup
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;On GitHub&lt;/strong&gt; — add these repository secrets (Settings → Secrets and variables → Actions):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_IP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your server's IP, e.g. &lt;code&gt;your-server-ip&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSH user, e.g. &lt;code&gt;deploy&lt;/code&gt; or &lt;code&gt;root&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVER_SSH_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;strong&gt;private&lt;/strong&gt; SSH key for that user (full text)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEPLOY_PATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Where the app lives on the server, e.g. &lt;code&gt;/home/apps/your-app&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GHCR_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A token that can read your registry images (used by the server to pull)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GHCR_USERNAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The username/org for the registry login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SONAR_TOKEN&lt;/code&gt; / &lt;code&gt;SONAR_HOST_URL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;If you use a quality gate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On the server&lt;/strong&gt; — install Docker + Compose, create the deploy directory and the secrets file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install Docker (official convenience script) and verify Compose v2.23.1+&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
docker compose version

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/apps/your-app
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/apps/your-app

&lt;span class="c"&gt;# Create the real .env from your template, then fill in generated secrets.&lt;/span&gt;
nano .env
&lt;span class="c"&gt;# POSTGRES_PASSWORD=...     (openssl rand -hex 32)&lt;/span&gt;
&lt;span class="c"&gt;# APP_DB_PASSWORD=...       (openssl rand -hex 32)&lt;/span&gt;
&lt;span class="c"&gt;# MIGRATOR_DB_PASSWORD=...  (openssl rand -hex 32)&lt;/span&gt;
&lt;span class="c"&gt;# JWT_SECRET=...            (openssl rand -hex 32)&lt;/span&gt;
&lt;span class="c"&gt;# S3_* = ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it for the server. You will not edit anything else here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every deploy after that
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Make your change IN THE REPO (code, or the compose file, or a workflow).&lt;/span&gt;
&lt;span class="c"&gt;# 2. ALWAYS validate the compose file before committing:&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml config &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"compose OK"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Commit and push to main:&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"your change"&lt;/span&gt;
git push origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then watch the Actions tab. The quality workflow runs, then the deploy workflow builds, scans, and ships. Done.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Make &lt;code&gt;docker compose config&lt;/code&gt; a reflex.&lt;/strong&gt; It parses and fully resolves the file (including anchors and &lt;code&gt;.env&lt;/code&gt; interpolation) in about a second. It catches the entire class of "the deploy died instantly on a YAML typo" problems &lt;em&gt;before&lt;/em&gt; you push. The vast majority of failed first deploys are a malformed compose file that this one command would have caught.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  10. Common errors and how to fix them
&lt;/h2&gt;

&lt;p&gt;This is the section I wish every tutorial had. Every one of these is real. They're roughly in the order you hit them as the pipeline gets further each time.&lt;/p&gt;

&lt;h3&gt;
  
  
  "My edits to the server file keep reverting!"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; you edited &lt;code&gt;docker-compose.prod.yml&lt;/code&gt; &lt;em&gt;on the server&lt;/em&gt;, but the pipeline copies the repo's version over it (&lt;code&gt;overwrite: true&lt;/code&gt;) on every deploy.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; edit the file in the &lt;strong&gt;repo&lt;/strong&gt;, not the server. The server copy is generated output. This is by design — it guarantees the server matches what's reviewed in git. Retrain the muscle memory: never edit on the box.&lt;/p&gt;
&lt;h3&gt;
  
  
  SSH step fails with "handshake failed" / "permission denied (publickey)"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the &lt;code&gt;SERVER_SSH_KEY&lt;/code&gt; secret is wrong, or the matching public key isn't in the server's &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; put the &lt;strong&gt;private&lt;/strong&gt; key (the whole thing, including the BEGIN/END lines) in the secret. Add its public half to &lt;code&gt;authorized_keys&lt;/code&gt; for &lt;code&gt;SERVER_USER&lt;/code&gt;. Test locally first: &lt;code&gt;ssh -i your_key user@your-server-ip&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Registry login fails: "Error: Cannot perform an interactive login from a non TTY device" or empty password
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the registry token secret is empty or unset, so the &lt;code&gt;docker login&lt;/code&gt; gets no password and tries to go interactive.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; set &lt;code&gt;GHCR_TOKEN&lt;/code&gt; (and &lt;code&gt;GHCR_USERNAME&lt;/code&gt;). Always pipe it: &lt;code&gt;echo "$GHCR_TOKEN" | docker login ghcr.io -u "$USER" --password-stdin&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  "stat /path/.env.docker: no such file or directory"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the compose file references an env file (&lt;code&gt;env_file: .env.docker&lt;/code&gt;) that doesn't exist on the server.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; either create that file, or — better — drop the separate env file and define config inline in the compose &lt;code&gt;x-app-env&lt;/code&gt; block, reading secrets from the standard &lt;code&gt;.env&lt;/code&gt;. One fewer file to manage.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;yaml: line 2: mapping values are not allowed in this context&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the compose file is malformed — almost always near the top. The classic version: the &lt;code&gt;x-app-env: &amp;amp;app-env&lt;/code&gt; anchor line got deleted (often during hand-edits or a bad copy-paste), leaving the env keys with no parent, or a comment lost its leading &lt;code&gt;#&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; restore the structure. Confirm the anchor exists and the references match. Then &lt;strong&gt;&lt;code&gt;docker compose config&lt;/code&gt;&lt;/strong&gt; to verify it parses &lt;em&gt;before&lt;/em&gt; committing. If you copied the file from somewhere and it got mangled, download the raw file instead of pasting, pasted text can drop indentation or lines.&lt;/p&gt;
&lt;h3&gt;
  
  
  Migration fails: &lt;code&gt;schema "..." does not exist&lt;/code&gt; (Postgres code 3F000)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the ORM tries to create its &lt;code&gt;migrations&lt;/code&gt; bookkeeping table inside a custom schema &lt;em&gt;before&lt;/em&gt; the migration that would create that schema has run — a chicken-and-egg on a fresh database.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; pre-create the schema in your DB init SQL: &lt;code&gt;CREATE SCHEMA IF NOT EXISTS your_schema AUTHORIZATION migrator_user;&lt;/code&gt;. &lt;strong&gt;Important:&lt;/strong&gt; init SQL only runs on a &lt;em&gt;fresh, empty&lt;/em&gt; volume. If your volume already exists, also create the schema manually once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; appdb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"CREATE SCHEMA IF NOT EXISTS your_schema AUTHORIZATION migrator_user;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Migration fails: &lt;code&gt;permission denied for database&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the migrator role can create schemas/tables but wasn't granted &lt;code&gt;CREATE&lt;/code&gt; on the database itself.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; in init SQL: &lt;code&gt;GRANT CREATE ON DATABASE appdb TO migrator_user;&lt;/code&gt; (and re-run the manual grant if the volume already exists).&lt;/p&gt;
&lt;h3&gt;
  
  
  App can't connect: &lt;code&gt;unsupported startup parameter: statement_timeout&lt;/code&gt; (then &lt;code&gt;lock_timeout&lt;/code&gt;, etc.)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; your DB driver sets session parameters as connection "startup parameters". PgBouncer in &lt;strong&gt;transaction&lt;/strong&gt; pooling mode rejects any it isn't told to allow — and it surfaces them one at a time, so you fix one and hit the next.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; allow the whole set at once on the pgbouncer service:&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;IGNORE_STARTUP_PARAMETERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;extra_float_digits,statement_timeout,lock_timeout,idle_in_transaction_session_timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enforce the actual timeouts at the &lt;strong&gt;role&lt;/strong&gt; level in init SQL (&lt;code&gt;ALTER ROLE ... SET statement_timeout = ...&lt;/code&gt;), because under transaction pooling per-session SETs don't reliably stick.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploy reports "did not become healthy in time" but the app log says it started
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the health gate is stricter or faster than the app's real startup, &lt;strong&gt;or&lt;/strong&gt; the health-check path/port is wrong.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; first confirm the app actually serves the health route from inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;api wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://localhost:4000/api/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns OK, it's a timing issue — raise &lt;code&gt;start_period&lt;/code&gt; and the deploy's wait budget. If it 404s, fix the path in the healthcheck &lt;code&gt;test:&lt;/code&gt;. If it says &lt;code&gt;wget: not found&lt;/code&gt;, your runtime image lacks wget — install it or use a &lt;code&gt;node&lt;/code&gt;/&lt;code&gt;curl&lt;/code&gt;-based check.&lt;/p&gt;

&lt;h3&gt;
  
  
  The database "disappeared" after I renamed something
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; you changed the compose &lt;code&gt;name:&lt;/code&gt; (project name). Volumes are namespaced by project name, so the stack now points at a new, empty volume. Your old data is still on disk under the old name.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; &lt;strong&gt;never change the project name.&lt;/strong&gt; To find orphaned data: &lt;code&gt;docker volume ls | grep postgres&lt;/code&gt;. This is why a &lt;code&gt;pg_dump&lt;/code&gt; backup before any risky change is non-negotiable in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quality gate fails with HTTP 404 (self-hosted scanner)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the gate step didn't get the scanner host URL, so it defaulted to the cloud service and couldn't find your project.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; pass &lt;code&gt;SONAR_HOST_URL&lt;/code&gt; on &lt;strong&gt;both&lt;/strong&gt; the scan and the gate steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependabot: "security update not possible"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; a vulnerable transitive dependency has no version that satisfies everything else's constraints yet. Common for deep dev-only dependencies.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; if it's dev-only and below your production audit threshold, it's safe to leave until the ecosystem catches up, or add a temporary override/resolution. Don't let a dev-only advisory block production.&lt;/p&gt;

&lt;h3&gt;
  
  
  A service crashes with an application error (e.g. &lt;code&gt;TypeError: ... is not iterable&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; this is &lt;strong&gt;not&lt;/strong&gt; an infrastructure problem the image built and deployed fine; the app code itself is throwing on boot.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; read it as a signal your pipeline is &lt;em&gt;working&lt;/em&gt; it caught a real code bug before declaring success. This belongs to whoever owns that part of the application code, not to the deploy config. No amount of compose/workflow tweaking fixes a code bug. Hand it to the right developer with the exact stack trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. A pre-deploy checklist
&lt;/h2&gt;

&lt;p&gt;Pin this somewhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;docker compose -f docker-compose.prod.yml config&lt;/code&gt; passes locally&lt;/li&gt;
&lt;li&gt;[ ] All changes are in the &lt;strong&gt;repo&lt;/strong&gt;, nothing edited directly on the server&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;.env&lt;/code&gt; exists on the server with every required key filled in (URL-safe secrets via &lt;code&gt;openssl rand -hex 32&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] The compose project &lt;code&gt;name:&lt;/code&gt; is unchanged&lt;/li&gt;
&lt;li&gt;[ ] All required GitHub secrets are set&lt;/li&gt;
&lt;li&gt;[ ] Third-party actions are pinned to SHAs&lt;/li&gt;
&lt;li&gt;[ ] Healthcheck path/port match what your app actually serves&lt;/li&gt;
&lt;li&gt;[ ] You have a recent database backup (&lt;code&gt;pg_dump&lt;/code&gt;) before any risky change&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. Closing lessons
&lt;/h2&gt;

&lt;p&gt;A few things that, in hindsight, mattered more than any single config line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth.&lt;/strong&gt; Edit in the repo; let the server be disposable. Half-adopting this (editing in both places) is worse than not adopting it at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate before you push.&lt;/strong&gt; &lt;code&gt;docker compose config&lt;/code&gt; turns a 3-minute failed pipeline into a 1-second local check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors get &lt;em&gt;deeper&lt;/em&gt;, which is progress.&lt;/strong&gt; A YAML parse error → a migration error → a connection error → an app boot error is not "still broken" it's each layer passing in turn. Read the &lt;em&gt;new&lt;/em&gt; error as a checkpoint reached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know the boundary between infra and app.&lt;/strong&gt; Connection params, schemas, health timing: infra. A &lt;code&gt;TypeError&lt;/code&gt; in your own code: not infra. Recognizing which is which saves you from "fixing" the wrong file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect your data.&lt;/strong&gt; Pin the project name, never &lt;code&gt;down -v&lt;/code&gt; casually, and back up before risky changes. Containers are disposable; your database is not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Happy shipping.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>automation</category>
    </item>
    <item>
      <title>Setting up a realistic, multi-machine environment on your own laptop used to mean juggling VirtualBox windows, clicking through installers, and hoping you could reproduce the same setup tomorrow. Vagrant replaces all of that with a single text file.</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:49:47 +0000</pubDate>
      <link>https://dev.to/saint_vandora/setting-up-a-realistic-multi-machine-environment-on-your-own-laptop-used-to-mean-juggling-1lb2</link>
      <guid>https://dev.to/saint_vandora/setting-up-a-realistic-multi-machine-environment-on-your-own-laptop-used-to-mean-juggling-1lb2</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880" class="crayons-story__hidden-navigation-link"&gt;Building a Multi-VM Lab with Vagrant: Two Web Servers and a Database&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/saint_vandora" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" alt="saint_vandora profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/saint_vandora" class="crayons-story__secondary fw-medium m:hidden"&gt;
              FOLASAYO SAMUEL OLAYEMI
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                FOLASAYO SAMUEL OLAYEMI
                
              
              &lt;div id="story-author-preview-content-3976713" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/saint_vandora" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F720588%2F0168bd20-9ed8-4499-b590-b651c8202e85.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;FOLASAYO SAMUEL OLAYEMI&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 24&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880" id="article-link-3976713"&gt;
          Building a Multi-VM Lab with Vagrant: Two Web Servers and a Database
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/automation"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;automation&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>automation</category>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
