<?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>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" width="800" height="800"&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="" width="800" height="800"&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="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;5&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" width="800" height="800"&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="" width="800" height="800"&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="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;5&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>
    <item>
      <title>Building a Multi-VM Lab with Vagrant: Two Web Servers and a Database</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:48:37 +0000</pubDate>
      <link>https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880</link>
      <guid>https://dev.to/saint_vandora/building-a-multi-vm-lab-with-vagrant-two-web-servers-and-a-database-2880</guid>
      <description>&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;In this article we'll build a three-machine lab, two Ubuntu web servers and one CentOS database server, wired together on a private network, with the database node automatically provisioned at boot.&lt;/p&gt;

&lt;p&gt;By the end you'll understand not just &lt;em&gt;what&lt;/em&gt; the configuration says, but &lt;em&gt;why&lt;/em&gt; each line is there and what can go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;The target topology is simple but representative of a real application stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;VM&lt;/th&gt;
&lt;th&gt;Operating System&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Private IP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;web01&lt;/td&gt;
&lt;td&gt;Ubuntu 20.04 LTS&lt;/td&gt;
&lt;td&gt;Web server&lt;/td&gt;
&lt;td&gt;192.168.56.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;web02&lt;/td&gt;
&lt;td&gt;Ubuntu 20.04 LTS&lt;/td&gt;
&lt;td&gt;Web server&lt;/td&gt;
&lt;td&gt;192.168.56.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;db01&lt;/td&gt;
&lt;td&gt;CentOS 7&lt;/td&gt;
&lt;td&gt;Database server&lt;/td&gt;
&lt;td&gt;192.168.56.13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three machines sit on the same host-only private network (&lt;code&gt;192.168.56.0/24&lt;/code&gt;), which lets them talk to each other while staying isolated from the outside world. The database node, &lt;code&gt;db01&lt;/code&gt;, gets a provisioning script that sets its hostname, populates &lt;code&gt;/etc/hosts&lt;/code&gt;, and installs a running MariaDB instance, so the moment &lt;code&gt;vagrant up&lt;/code&gt; finishes, the box is ready to accept connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Word on Vagrant
&lt;/h2&gt;

&lt;p&gt;Vagrant is an infrastructure-as-code tool for managing virtual machines. You describe the machines you want in a file called a &lt;code&gt;Vagrantfile&lt;/code&gt;, and Vagrant talks to a &lt;em&gt;provider&lt;/em&gt; (VirtualBox by default) to create them. The two ideas that make Vagrant powerful are &lt;strong&gt;boxes&lt;/strong&gt; and &lt;strong&gt;provisioners&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;em&gt;box&lt;/em&gt; is a pre-packaged base image, for example &lt;code&gt;ubuntu/focal64&lt;/code&gt; is a minimal Ubuntu 20.04 install. Instead of running an OS installer, Vagrant downloads the box once and clones it for each machine. A &lt;em&gt;provisioner&lt;/em&gt; is the code that runs the first time a machine boots (and again on demand), turning a generic base image into the specific server you need. Together they give you environments that are disposable, repeatable, and version-controllable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Vagrantfile
&lt;/h2&gt;

&lt;p&gt;Here is the configuration in full. We'll dissect it section by section afterward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# -*- mode: ruby -*-&lt;/span&gt;
&lt;span class="c1"&gt;# vi: set ft=ruby :&lt;/span&gt;

&lt;span class="no"&gt;Vagrant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;

  &lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
  &lt;span class="c1"&gt;# web01 - Ubuntu 20.04 (Focal Fossa)&lt;/span&gt;
  &lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="s2"&gt;"web01"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu/focal64"&lt;/span&gt;
    &lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"web01"&lt;/span&gt;
    &lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;network&lt;/span&gt; &lt;span class="s2"&gt;"private_network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ip: &lt;/span&gt;&lt;span class="s2"&gt;"192.168.56.11"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
  &lt;span class="c1"&gt;# web02 - Ubuntu 20.04 (Focal Fossa)&lt;/span&gt;
  &lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="s2"&gt;"web02"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;web02&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;web02&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu/focal64"&lt;/span&gt;
    &lt;span class="n"&gt;web02&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"web02"&lt;/span&gt;
    &lt;span class="n"&gt;web02&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;network&lt;/span&gt; &lt;span class="s2"&gt;"private_network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ip: &lt;/span&gt;&lt;span class="s2"&gt;"192.168.56.12"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
  &lt;span class="c1"&gt;# db01 - CentOS 7  (with provisioning + hostname configuration)&lt;/span&gt;
  &lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="s2"&gt;"db01"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;db01&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;db01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"centos/7"&lt;/span&gt;
    &lt;span class="n"&gt;db01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db01"&lt;/span&gt;
    &lt;span class="n"&gt;db01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;network&lt;/span&gt; &lt;span class="s2"&gt;"private_network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ip: &lt;/span&gt;&lt;span class="s2"&gt;"192.168.56.13"&lt;/span&gt;

    &lt;span class="c1"&gt;# Provisioning for db01&lt;/span&gt;
    &lt;span class="n"&gt;db01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;provision&lt;/span&gt; &lt;span class="s2"&gt;"shell"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;inline: &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="no"&gt;SHELL&lt;/span&gt;&lt;span class="sh"&gt;
      echo "==== Provisioning db01 ===="

      # Ensure hostname is configured persistently
      hostnamectl set-hostname db01

      # Add host entries for the other VMs (handy for app -&amp;gt; db lookups)
      cat &amp;gt;&amp;gt; /etc/hosts &amp;lt;&amp;lt;-HOSTS
        192.168.56.11  web01
        192.168.56.12  web02
        192.168.56.13  db01
      HOSTS

      # Update packages and install MariaDB (MySQL) server
      yum update -y
      yum install -y mariadb-server mariadb

      # Enable and start the database service
      systemctl enable mariadb
      systemctl start mariadb

      echo "==== db01 provisioning complete ===="
&lt;/span&gt;&lt;span class="no"&gt;    SHELL&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Breaking It Down
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The configuration block
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Vagrant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Vagrantfile is written in Ruby, and everything lives inside a single configuration block. The &lt;code&gt;"2"&lt;/code&gt; is the configuration version, version 2 has been the standard for years and covers every modern Vagrant release. The &lt;code&gt;config&lt;/code&gt; object passed into the block is the handle through which you describe global settings and individual machines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining multiple machines
&lt;/h3&gt;

&lt;p&gt;The key to a multi-VM setup is &lt;code&gt;config.vm.define&lt;/code&gt;. Each call carves out a named machine with its own nested configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="s2"&gt;"web01"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name you pass (&lt;code&gt;"web01"&lt;/code&gt;) becomes the identifier you use on the command line, for example &lt;code&gt;vagrant ssh web01&lt;/code&gt; or &lt;code&gt;vagrant provision db01&lt;/code&gt;. Inside the block you configure that machine in isolation, which is what lets web01, web02, and db01 run different operating systems and carry different settings within one file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing a box
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu/focal64"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ubuntu/focal64&lt;/code&gt; is the official Ubuntu 20.04 LTS box (Focal Fossa is the 20.04 codename). The database node instead uses &lt;code&gt;centos/7&lt;/code&gt;. Boxes are downloaded from Vagrant Cloud the first time they're needed and then cached locally, so subsequent machines using the same box start almost instantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting the hostname
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"web01"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Vagrant to set the machine's hostname during boot. It's a convenience that keeps your shell prompts readable and gives each box a stable identity. For &lt;code&gt;db01&lt;/code&gt; we &lt;em&gt;also&lt;/em&gt; set the hostname inside the provisioning script with &lt;code&gt;hostnamectl&lt;/code&gt; — more on why below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Private networking
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;web01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;network&lt;/span&gt; &lt;span class="s2"&gt;"private_network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ip: &lt;/span&gt;&lt;span class="s2"&gt;"192.168.56.11"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;private_network&lt;/code&gt; creates a host-only network: the VMs can reach each other and the host machine, but they aren't directly exposed to the wider LAN or internet. Assigning static IPs in the same subnet (&lt;code&gt;192.168.56.x&lt;/code&gt;) means web01 can always find db01 at &lt;code&gt;192.168.56.13&lt;/code&gt;, which is exactly what an application server needs to locate its database.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;192.168.56.0/24&lt;/code&gt; range matters. Recent versions of VirtualBox restrict which host-only networks are allowed for security reasons, and &lt;code&gt;192.168.56.0/21&lt;/code&gt; is the default permitted range. Using an IP outside it will cause Vagrant to fail with a network validation error unless you explicitly whitelist the range in &lt;code&gt;/etc/vbox/networks.conf&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Provisioning the database node
&lt;/h3&gt;

&lt;p&gt;The most interesting part is the shell provisioner attached to &lt;code&gt;db01&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;db01&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;provision&lt;/span&gt; &lt;span class="s2"&gt;"shell"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;inline: &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="no"&gt;SHELL&lt;/span&gt;&lt;span class="sh"&gt;
  ...
&lt;/span&gt;&lt;span class="no"&gt;SHELL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;&amp;lt;-SHELL ... SHELL&lt;/code&gt; syntax is a Ruby &lt;em&gt;heredoc&lt;/em&gt;, a multi-line string that Vagrant uploads to the guest and runs as root on first boot. Walking through what it does:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hostname configuration.&lt;/strong&gt; &lt;code&gt;hostnamectl set-hostname db01&lt;/code&gt; writes the hostname persistently through systemd. Even though Vagrant's &lt;code&gt;hostname&lt;/code&gt; setting already does this, calling &lt;code&gt;hostnamectl&lt;/code&gt; inside the script makes the intent explicit and guarantees it survives regardless of box quirks, a small belt-and-suspenders habit that's common in real provisioning code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Host resolution.&lt;/strong&gt; The script appends entries to &lt;code&gt;/etc/hosts&lt;/code&gt; so that names like &lt;code&gt;web01&lt;/code&gt; and &lt;code&gt;web02&lt;/code&gt; resolve to their private IPs from inside &lt;code&gt;db01&lt;/code&gt;. In a lab without a DNS server, this is the simplest way to let machines refer to each other by name instead of memorizing addresses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package installation.&lt;/strong&gt; &lt;code&gt;yum update -y&lt;/code&gt; refreshes the package metadata, then &lt;code&gt;yum install -y mariadb-server mariadb&lt;/code&gt; pulls in the MariaDB database engine (the community fork of MySQL that ships in the CentOS 7 repositories).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service management.&lt;/strong&gt; Finally, &lt;code&gt;systemctl enable mariadb&lt;/code&gt; makes the database start automatically on every boot, and &lt;code&gt;systemctl start mariadb&lt;/code&gt; brings it up immediately so you don't have to reboot to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Lab
&lt;/h2&gt;

&lt;p&gt;With the &lt;code&gt;Vagrantfile&lt;/code&gt; saved in an empty directory, the workflow is short:&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;# Bring up all three machines&lt;/span&gt;
vagrant up

&lt;span class="c"&gt;# SSH into any machine by name&lt;/span&gt;
vagrant ssh db01

&lt;span class="c"&gt;# Re-run only db01's provisioning after editing the script&lt;/span&gt;
vagrant provision db01

&lt;span class="c"&gt;# Check the status of every machine&lt;/span&gt;
vagrant status

&lt;span class="c"&gt;# Tear everything down when you're done&lt;/span&gt;
vagrant destroy &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;vagrant up&lt;/code&gt; will take a few minutes as boxes download and &lt;code&gt;db01&lt;/code&gt; runs its provisioning. Subsequent boots are much faster because the boxes are already cached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;A few things tend to trip people up the first time they run a setup like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CentOS 7 box is end-of-life.&lt;/strong&gt; CentOS 7 reached end of life in mid-2024, and its package mirrors have been moving to the CentOS Vault. If &lt;code&gt;yum update&lt;/code&gt; fails to reach a mirror, you may need to repoint yum at the vault URLs, or switch to a maintained box such as &lt;code&gt;generic/centos7&lt;/code&gt;. For brand-new labs, consider Rocky Linux or AlmaLinux as drop-in CentOS successors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Host-only network range.&lt;/strong&gt; As noted earlier, if you pick IPs outside &lt;code&gt;192.168.56.0/21&lt;/code&gt; you'll hit a VirtualBox validation error. Either stay within the default range or add your range to &lt;code&gt;/etc/vbox/networks.conf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heredoc indentation.&lt;/strong&gt; When you append to &lt;code&gt;/etc/hosts&lt;/code&gt; using a nested heredoc, leading whitespace from the indented script lines is carried into the file. It's harmless for &lt;code&gt;/etc/hosts&lt;/code&gt; parsing, but if you want perfectly clean entries, use &lt;code&gt;&amp;lt;&amp;lt;-HOSTS&lt;/code&gt; with tab indentation (which the dash strips) or drop the indentation entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provisioning only runs once.&lt;/strong&gt; By default, provisioners execute only on the first &lt;code&gt;vagrant up&lt;/code&gt; or when you explicitly call &lt;code&gt;vagrant provision&lt;/code&gt;. Editing the script and re-running &lt;code&gt;vagrant up&lt;/code&gt; on an already-running machine won't re-provision it, use &lt;code&gt;vagrant provision db01&lt;/code&gt; or &lt;code&gt;vagrant reload --provision&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;This lab is a foundation. Natural extensions include adding provisioning to the two web servers so they install and start Nginx or Apache, configuring the web tier to connect to MariaDB on &lt;code&gt;db01&lt;/code&gt;, securing the database with &lt;code&gt;mysql_secure_installation&lt;/code&gt;, or moving from inline shell scripts to a configuration-management tool like Ansible once your provisioning logic grows beyond a handful of commands. You might also tune the VM resources (CPU and memory) per machine using a &lt;code&gt;config.vm.provider&lt;/code&gt; block when the default allocation isn't enough.&lt;/p&gt;

&lt;p&gt;The real value of expressing all of this in a &lt;code&gt;Vagrantfile&lt;/code&gt; is reproducibility: anyone who clones your repository and runs &lt;code&gt;vagrant up&lt;/code&gt; gets the exact same three-machine environment, every time, on any machine that has Vagrant and VirtualBox installed. That predictability is the whole point, and it's why a few dozen lines of Ruby can replace an afternoon of manual setup.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>automation</category>
    </item>
    <item>
      <title>One of the most confusing errors you can face while deploying a Node.js or Docker-based application</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Tue, 23 Jun 2026 21:50:34 +0000</pubDate>
      <link>https://dev.to/saint_vandora/one-of-the-most-confusing-errors-you-can-face-while-deploying-a-nodejs-or-docker-based-application-2f6k</link>
      <guid>https://dev.to/saint_vandora/one-of-the-most-confusing-errors-you-can-face-while-deploying-a-nodejs-or-docker-based-application-2f6k</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np" class="crayons-story__hidden-navigation-link"&gt;Fixing “Git Divergent Branches” on a Production Server (Real DevOps Debugging Walkthrough)&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-3973591" 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/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 23&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/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np" id="article-link-3973591"&gt;
          Fixing “Git Divergent Branches” on a Production Server (Real DevOps Debugging Walkthrough)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&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/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&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/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np" 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;7&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/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              2&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&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>
    </item>
    <item>
      <title>Fixing “Git Divergent Branches” on a Production Server (Real DevOps Debugging Walkthrough)</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Tue, 23 Jun 2026 21:48:29 +0000</pubDate>
      <link>https://dev.to/saint_vandora/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np</link>
      <guid>https://dev.to/saint_vandora/fixing-git-divergent-branches-on-a-production-server-real-devops-debugging-walkthrough-48np</guid>
      <description>&lt;p&gt;One of the most confusing errors you can face while deploying a Node.js or Docker-based application is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fatal: Need to specify how to reconcile divergent branches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, it looks like a Git bug. In reality, it is Git doing exactly what it should do, protecting you from overwriting history.&lt;/p&gt;

&lt;p&gt;In this article, I’ll break down a real production incident where a deployment failed due to divergent Git branches, how we diagnosed it, and the correct DevOps fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A simple deployment script was running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull
docker compose down &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But it failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fatal: Need to specify how to reconcile divergent branches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stopped deployment completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Git Was Telling Us
&lt;/h2&gt;

&lt;p&gt;To understand the issue, we ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git rev-list &lt;span class="nt"&gt;--left-right&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; HEAD...origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 commit exists locally on the server&lt;/li&gt;
&lt;li&gt;16 commits exist on GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the branches had diverged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Happens (Important)
&lt;/h2&gt;

&lt;p&gt;This usually happens when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Someone runs &lt;code&gt;git commit&lt;/code&gt; directly on a server&lt;/li&gt;
&lt;li&gt;A previous deployment used &lt;code&gt;git pull&lt;/code&gt; with merge commits&lt;/li&gt;
&lt;li&gt;History between local and remote is no longer linear&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Git refuses to guess whether you want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Merge&lt;/li&gt;
&lt;li&gt;Rebase&lt;/li&gt;
&lt;li&gt;Or reject changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So it throws an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep Diagnosis
&lt;/h2&gt;

&lt;p&gt;We inspected the commits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; origin/main..HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6d9046b Merge pull request #222
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; HEAD..origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Showed multiple new GitHub PR merges.&lt;/p&gt;

&lt;p&gt;Conclusion:&lt;/p&gt;

&lt;p&gt;The server was behind GitHub&lt;br&gt;
The “local commit” was already part of repo history&lt;br&gt;
No real production changes existed on server&lt;/p&gt;
&lt;h2&gt;
  
  
  The Real Fix (Production Safe)
&lt;/h2&gt;

&lt;p&gt;For deployment servers, you should NEVER rely on &lt;code&gt;git pull&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Instead, use a deterministic reset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch origin
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then redeploy:&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 down &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;p&gt;This approach ensures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server always matches GitHub exactly&lt;/li&gt;
&lt;li&gt;No merge conflicts in production&lt;/li&gt;
&lt;li&gt;No accidental local commits survive&lt;/li&gt;
&lt;li&gt;Fully reproducible deployments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the standard CI/CD pattern used in production environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broken Approach
&lt;/h2&gt;

&lt;p&gt;Avoid this on servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;May trigger merge conflicts&lt;/li&gt;
&lt;li&gt;Depends on local history state&lt;/li&gt;
&lt;li&gt;Can break deployments unexpectedly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best Practice Deployment Script
&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;cd &lt;/span&gt;opt/yourprojectdirectory

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Fetching latest code..."&lt;/span&gt;
git fetch origin

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Resetting to latest main..."&lt;/span&gt;
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; origin/main

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Rebuilding containers..."&lt;/span&gt;
docker compose down &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Deployment successful"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key Lesson
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Production servers should not “merge code.”&lt;br&gt;
They should &lt;strong&gt;mirror GitHub exactly&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;This issue looks scary at first, but it’s actually a simple Git history mismatch problem.&lt;/p&gt;

&lt;p&gt;Once you understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HEAD&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;origin/main&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;divergence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…you can fix it in under 30 seconds.&lt;/p&gt;

&lt;p&gt;If you're doing DevOps or managing deployments, this is one of those fundamentals that will save you from late-night production panic.&lt;/p&gt;

&lt;p&gt;If you enjoyed this breakdown, I’ll share more real-world DevOps debugging stories like this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>It is one of the most frustrating experiences in online learning: you sit down to focus on a Udemy course, but the video player constantly pauses, freezes, or refuses to load. Meanwhile, YouTube, Netflix, and every other app on your Mac run perfectly fine.</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Sat, 13 Jun 2026 04:22:16 +0000</pubDate>
      <link>https://dev.to/saint_vandora/it-is-one-of-the-most-frustrating-experiences-in-online-learning-you-sit-down-to-focus-on-a-udemy-2f3</link>
      <guid>https://dev.to/saint_vandora/it-is-one-of-the-most-frustrating-experiences-in-online-learning-you-sit-down-to-focus-on-a-udemy-2f3</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-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc" class="crayons-story__hidden-navigation-link"&gt;How to Fix Udemy Videos Constantly Pausing on macOS (When Other Apps Work Fine)&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.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-3888652" 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.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-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 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/how-to-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc" id="article-link-3888652"&gt;
          How to Fix Udemy Videos Constantly Pausing on macOS (When Other Apps Work Fine)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/macos"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;macos&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/chrome"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;chrome&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/troubleshooting"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;troubleshooting&lt;/a&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;/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-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc" 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-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc#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;
            4 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>learning</category>
      <category>productivity</category>
      <category>software</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
