<?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>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>
    <item>
      <title>How to Fix Udemy Videos Constantly Pausing on macOS (When Other Apps Work Fine)</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Sat, 13 Jun 2026 03:56:07 +0000</pubDate>
      <link>https://dev.to/saint_vandora/how-to-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc</link>
      <guid>https://dev.to/saint_vandora/how-to-fix-udemy-videos-constantly-pausing-on-macos-when-other-apps-work-fine-3dcc</guid>
      <description>&lt;p&gt;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.&lt;br&gt;
Because other platforms work without a hitch, it is easy to assume the issue lies with Udemy's servers. However, the root cause is usually a silent conflict between your browser settings, macOS security features, and Udemy’s strict digital rights management (DRM) protections.&lt;br&gt;
If you are stuck on a looping loading wheel, here is exactly why it happens and how to fix it in less than two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick-Fix Troubleshooting Checklist
&lt;/h2&gt;

&lt;p&gt;Save or screenshot this step-by-step breakdown to instantly diagnose and fix your playback issues:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Open Chrome in Incognito Mode
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Open a new Incognito window (&lt;code&gt;Cmd + Shift + N&lt;/code&gt; on Mac).&lt;/li&gt;
&lt;li&gt;Try playing the video again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disable ad blockers.&lt;/li&gt;
&lt;li&gt;Disable VPN privacy shields or browser extensions one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If it still fails:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Continue to Step 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Disable Hardware Acceleration
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open Chrome.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings → System&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Turn off &lt;strong&gt;Use graphics acceleration when available&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Relaunch Chrome.&lt;/li&gt;
&lt;li&gt;Test the video again.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 3: Check Mac Security and Display Connections
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Disconnect any external monitors or docking stations.&lt;/li&gt;
&lt;li&gt;Close applications that may interfere with video playback:

&lt;ul&gt;
&lt;li&gt;Zoom&lt;/li&gt;
&lt;li&gt;Discord&lt;/li&gt;
&lt;li&gt;OBS Studio&lt;/li&gt;
&lt;li&gt;Screen recording tools&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Test video playback again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: Clear Temporary Browser Data
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open Chrome.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings → Privacy and Security → Clear Browsing Data&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select:

&lt;ul&gt;
&lt;li&gt;Cookies and other site data&lt;/li&gt;
&lt;li&gt;Cached images and files&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Clear the data.&lt;/li&gt;
&lt;li&gt;Restart Chrome and try again.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Still Not Working?
&lt;/h3&gt;

&lt;p&gt;If the issue persists after completing all four steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update Chrome to the latest version.&lt;/li&gt;
&lt;li&gt;Update macOS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Main Culprit: Hardware Acceleration Conflict
&lt;/h2&gt;

&lt;p&gt;The most common reason Udemy videos stutter or freeze on a Mac is a feature called Hardware Acceleration inside Google Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Hardware Acceleration?
&lt;/h2&gt;

&lt;p&gt;By default, Chrome offloads heavy visual tasks—like rendering high-definition video or 3D graphics—to your Mac’s dedicated graphics card (GPU). While this sounds efficient, outdated graphics drivers or macOS switching glitches can cause the browser to conflict with Udemy’s encrypted player.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it safe to disable?
&lt;/h2&gt;

&lt;p&gt;Yes. Turning off hardware acceleration simply instructs Chrome to process video playback using your Mac’s main processor (CPU) instead of the graphics card. Your data, extensions, and browser history remain completely safe, and you will not notice any performance drops for everyday browsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to turn it off in Chrome:
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Click the three vertical dots in the top-right corner of Chrome.&lt;/li&gt;
&lt;li&gt;Select Settings from the dropdown menu.&lt;/li&gt;
&lt;li&gt;Click on System in the left-hand sidebar.&lt;/li&gt;
&lt;li&gt;Locate "Use graphics acceleration when available" and toggle the switch to Off.&lt;/li&gt;
&lt;li&gt;Click the Relaunch button that appears next to the toggle to restart your browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Still Pausing? Check macOS Security and Extensions
&lt;/h2&gt;

&lt;p&gt;If adjusting your graphics settings does not fully solve the issue, Udemy’s strict piracy-protection protocols might be actively halting your stream. Look out for these two common macOS triggers:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Background Screen Recording &amp;amp; Mirroring Apps
&lt;/h2&gt;

&lt;p&gt;Udemy uses strict DRM to prevent users from recording copyrighted course material. Because of this, if macOS detects any active screen-capture or mirroring software, the Udemy player will instantly pause.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Close background apps like Zoom, Microsoft Teams, OBS, CleanShot X, or Discord before playing a video.&lt;/li&gt;
&lt;li&gt;Disconnect from external monitors or docking stations temporarily to see if a hardware connection is triggering the block.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Corrupted Browser Cache or Ad-Blockers
&lt;/h2&gt;

&lt;p&gt;Sometimes, aggressive browser extensions mistake Udemy’s security protocols for tracking scripts and block them entirely.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Try opening your course in a Chrome Incognito Window. If the video plays smoothly, a third-party extension (like an ad-blocker or VPN privacy shield) is causing the conflict. You will need to whitelist Udemy in that extension's settings.&lt;/li&gt;
&lt;li&gt;Clear your browser's cached files and cookies to wipe out any corrupted temporary data holding back the player.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Turn Hardware Acceleration Back On Later
&lt;/h2&gt;

&lt;p&gt;If you finish your course and want to re-enable your graphics card for heavy web tasks—like playing 3D browser games or editing complex graphics in Canva—you can turn it back on instantly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Chrome and click the three vertical dots (top right).&lt;/li&gt;
&lt;li&gt;Open Settings and click System.&lt;/li&gt;
&lt;li&gt;Toggle the "Use graphics acceleration when available" switch back to On (it will turn blue).&lt;/li&gt;
&lt;li&gt;Click Relaunch to apply the changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tip: If Udemy starts pausing again after you turn it back on, it means your Mac's graphics card drivers need an update, or a background recording app is still running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;You do not have to let technical glitches derail your learning momentum. By simply toggling off hardware acceleration in Chrome and clearing out background recording apps, you can bypass macOS playback conflicts and get back to mastering your next skill.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this useful, I create contents about DevOps, infrastructure, and backend engineering on &lt;a href="https://www.youtube.com/@TechPrane" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;, &lt;a href="https://saintvandora.hashnode.dev" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt; and &lt;a href="https://dev.to/saint_vandora"&gt;Dev.to&lt;/a&gt;. Follow along if this is the kind of problem-solving you want more of.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>chrome</category>
      <category>troubleshooting</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Fixing GHCR “Unauthorized” + Docker “Cannot perform interactive login from non-TTY” in GitHub Actions + SSH Deployments</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Thu, 11 Jun 2026 22:48:42 +0000</pubDate>
      <link>https://dev.to/saint_vandora/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-eig</link>
      <guid>https://dev.to/saint_vandora/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-eig</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9" class="crayons-story__hidden-navigation-link"&gt;Fixing GHCR “Unauthorized” + Docker “Cannot perform interactive login from non-TTY” in GitHub Actions + SSH Deployments&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-3857063" 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/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 11&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-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9" id="article-link-3857063"&gt;
          Fixing GHCR “Unauthorized” + Docker “Cannot perform interactive login from non-TTY” in GitHub Actions + SSH Deployments
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&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/infrastructure"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;infrastructure&lt;/a&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/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/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9" 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;11&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-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9#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;
            3 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>cicd</category>
      <category>docker</category>
      <category>githubactions</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fixing GHCR “Unauthorized” + Docker “Cannot perform interactive login from non-TTY” in GitHub Actions + SSH Deployments</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Thu, 11 Jun 2026 22:47:58 +0000</pubDate>
      <link>https://dev.to/saint_vandora/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9</link>
      <guid>https://dev.to/saint_vandora/fixing-ghcr-unauthorized-docker-cannot-perform-interactive-login-from-non-tty-in-github-24f9</guid>
      <description>&lt;p&gt;When deploying applications using &lt;strong&gt;GitHub Actions + SSH (appleboy/ssh-action)&lt;/strong&gt; and pulling images from &lt;strong&gt;GitHub Container Registry (GHCR)&lt;/strong&gt;, two common errors often appear together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unauthorized&lt;/code&gt; when pulling Docker images from GHCR&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Cannot perform an interactive login from a non TTY device&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These errors usually look confusing, but they both come from the same root issue: &lt;strong&gt;Docker authentication is not correctly handled in a non-interactive environment (CI/CD or SSH automation).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article explains what is happening, why it fails, and how to fix it permanently.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Problem (What You See in Logs)
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. GHCR Unauthorized Error
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Error response from daemon:
Head &lt;span class="s2"&gt;"https://ghcr.io/v2/ORG/REPO/manifests/latest"&lt;/span&gt;: unauthorized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means Docker tried to pull a private image from GHCR but &lt;strong&gt;was not authenticated&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Non-TTY Login Error
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Error: Cannot perform an interactive login from a non TTY device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means Docker tried to run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker login ghcr.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But failed because:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;GitHub Actions and SSH scripts do not support interactive input (no terminal to type username/password).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Root Cause
&lt;/h1&gt;

&lt;p&gt;Both errors come from the same issue:&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker login is either:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Missing entirely ❌&lt;/li&gt;
&lt;li&gt;Or written in interactive mode ❌&lt;/li&gt;
&lt;li&gt;Or not receiving credentials correctly ❌&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In CI/CD environments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is &lt;strong&gt;no keyboard input&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;There is &lt;strong&gt;no terminal (TTY)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Everything must be fully automated&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Correct Mental Model
&lt;/h1&gt;

&lt;p&gt;Think of GHCR like a private building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker pull&lt;/code&gt; = trying to enter the building&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker login&lt;/code&gt; = showing your ID card&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don’t show your ID &lt;strong&gt;before entering&lt;/strong&gt;, you get:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❌ unauthorized&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you try to “type your password manually” in automation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❌ non-TTY error&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Correct Fix (Production-Ready Solution)
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Step 1: Create a GitHub Token (PAT)
&lt;/h2&gt;

&lt;p&gt;Go to:&lt;/p&gt;

&lt;p&gt;GitHub → Settings → Developer Settings → Personal Access Tokens&lt;/p&gt;

&lt;p&gt;Create a token with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;read:packages&lt;/code&gt; ✅&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;repo&lt;/code&gt; (if private repo) ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Store Secrets in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Add these secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GHCR_USERNAME&lt;/code&gt; → your GitHub username&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GHCR_TOKEN&lt;/code&gt; → your personal access token&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Pass Secrets into SSH Action
&lt;/h2&gt;

&lt;p&gt;In your GitHub Actions workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="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 via SSH&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&lt;/span&gt;
  &lt;span class="na"&gt;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.HOST }}&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.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.SSH_KEY }}&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_USERNAME,GHCR_TOKEN&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin&lt;/span&gt;
      &lt;span class="s"&gt;docker compose pull&lt;/span&gt;
      &lt;span class="s"&gt;docker compose up -d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Step 4: Use Non-Interactive Docker Login (IMPORTANT)
&lt;/h1&gt;

&lt;h3&gt;
  
  
  ✔️ Correct way:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$GHCR_TOKEN&lt;/span&gt; | docker login ghcr.io &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$GHCR_USERNAME&lt;/span&gt; &lt;span class="nt"&gt;--password-stdin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wrong way (causes TTY error):
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker login ghcr.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Step 5: Verify Login on Server
&lt;/h1&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.docker/config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If login worked, you should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"auths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ghcr.io"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Final Deployment Flow (Correct Order)
&lt;/h1&gt;

&lt;p&gt;Your SSH deployment script should always follow this pattern:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Logging into GHCR..."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$GHCR_TOKEN&lt;/span&gt; | docker login ghcr.io &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$GHCR_USERNAME&lt;/span&gt; &lt;span class="nt"&gt;--password-stdin&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Pulling latest images..."&lt;/span&gt;
docker compose pull

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Restarting containers..."&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Cleaning unused images..."&lt;/span&gt;
docker system prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Common Mistakes That Cause This Issue
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. Using interactive login
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker login ghcr.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;❌ Breaks in CI/CD&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Forgetting to pass environment variables into SSH
&lt;/h2&gt;

&lt;p&gt;If you don’t include:&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;envs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GHCR_TOKEN,GHCR_USERNAME&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the server receives empty values → login fails silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Using wrong image name or tag
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ghcr.io/org/repo:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Image exists&lt;/li&gt;
&lt;li&gt;Tag exists (&lt;code&gt;latest&lt;/code&gt; or versioned tag)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Missing package permissions
&lt;/h2&gt;

&lt;p&gt;Your token must have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;read:packages&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise GHCR will always return &lt;code&gt;unauthorized&lt;/code&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why This Only Happens in DevOps Automation
&lt;/h1&gt;

&lt;p&gt;This issue is extremely common in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions&lt;/li&gt;
&lt;li&gt;Docker CI/CD pipelines&lt;/li&gt;
&lt;li&gt;SSH deployment scripts&lt;/li&gt;
&lt;li&gt;Kubernetes init containers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because all of them are:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Non-interactive environments (no human input allowed)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Summary
&lt;/h1&gt;

&lt;p&gt;If you remember only one thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❗ GHCR pull failures in CI/CD = ALWAYS authentication problem&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unauthorized&lt;/code&gt; → no valid login&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;non-TTY login error&lt;/code&gt; → you used interactive login incorrectly&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Final Working Checklist
&lt;/h1&gt;

&lt;p&gt;✔ Use &lt;code&gt;docker login --password-stdin&lt;/code&gt;&lt;br&gt;
✔ Pass secrets into SSH (&lt;code&gt;envs:&lt;/code&gt;)&lt;br&gt;
✔ Ensure token has &lt;code&gt;read:packages&lt;/code&gt;&lt;br&gt;
✔ Ensure image exists in GHCR&lt;br&gt;
✔ Avoid interactive commands in CI/CD&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this useful, I create contents about DevOps, infrastructure, and backend engineering on &lt;a href="https://www.youtube.com/@TechPrane" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;, &lt;a href="https://saintvandora.hashnode.dev" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt; and &lt;a href="https://dev.to/saint_vandora"&gt;Dev.to&lt;/a&gt;. Follow along if this is the kind of problem-solving you want more of.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>infrastructure</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Recovered 35GB on a Production Server by Moving Docker Builds Off It</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Thu, 11 Jun 2026 10:37:28 +0000</pubDate>
      <link>https://dev.to/saint_vandora/how-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-1cmb</link>
      <guid>https://dev.to/saint_vandora/how-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-1cmb</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/saint_vandora/how-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2" class="crayons-story__hidden-navigation-link"&gt;How I Recovered 35GB on a Production Server by Moving Docker Builds Off It&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-3864588" 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-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 11&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-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2" id="article-link-3864588"&gt;
          How I Recovered 35GB on a Production Server by Moving Docker Builds Off It
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&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/infrastructure"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;infrastructure&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;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/saint_vandora/how-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2" 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-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

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

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

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

&lt;/div&gt;


</description>
      <category>devops</category>
      <category>docker</category>
      <category>infrastructure</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Recovered 35GB on a Production Server by Moving Docker Builds Off It</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Thu, 11 Jun 2026 10:37:06 +0000</pubDate>
      <link>https://dev.to/saint_vandora/how-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2</link>
      <guid>https://dev.to/saint_vandora/how-i-recovered-35gb-on-a-production-server-by-moving-docker-builds-off-it-e2</guid>
      <description>&lt;p&gt;&lt;em&gt;And why your server should never be your build machine&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It started with a simple task, deploy a new service on a DigitalOcean droplet already running a dozen containerized apps. But the moment I SSHed in, the MOTD stopped me cold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Usage of /: 100.0% of 154.88GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100%. Not 92%. Not "you should look at this soon." A hundred percent. On a production server running over a dozen live services.&lt;/p&gt;

&lt;p&gt;This is the story of how I diagnosed it, fixed it surgically without touching a single running service, and more importantly, how I changed the architectural pattern that caused it in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, Understand the Terrain
&lt;/h2&gt;

&lt;p&gt;Before running a single cleanup command, I did what every senior engineer does when something is wrong in production: &lt;strong&gt;I measured everything first.&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;docker system &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the command that tells you the truth. Not &lt;code&gt;df -h&lt;/code&gt; alone, not vibes this. It breaks down exactly how Docker is consuming your disk: images, containers, volumes, and build cache, with per-item detail.&lt;/p&gt;

&lt;p&gt;Here's what came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Images space usage:     ~8GB total
Containers space usage: ~50MB
Local Volumes:          ~4.1GB (two anonymous volumes with 0 links)
Build cache usage:      29.22GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The containers themselves? Barely anything. The images? Manageable. The build cache? &lt;strong&gt;29.22 gigabytes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That single number told me everything I needed to know about what had been happening on this server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Docker Build Cache and Why Does It Silently Destroy You?
&lt;/h2&gt;

&lt;p&gt;When Docker builds an image, it executes your &lt;code&gt;Dockerfile&lt;/code&gt; instruction by instruction. Each instruction &lt;code&gt;RUN&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;FROM&lt;/code&gt; produces a &lt;strong&gt;layer&lt;/strong&gt;. Docker is smart: if a layer hasn't changed since the last build, it reuses the cached version instead of recomputing it.&lt;/p&gt;

&lt;p&gt;This is what makes &lt;code&gt;docker build&lt;/code&gt; fast on a developer machine or a CI runner. First build is slow. Second build? Docker says &lt;em&gt;"I've seen this before"&lt;/em&gt; and skips to the parts that changed.&lt;/p&gt;

&lt;p&gt;But here's the part nobody talks about enough: &lt;strong&gt;that cache lives on disk. Permanently. Until you explicitly remove it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every build you run on a server adds to it. Every time you push a new image and Docker re-resolves your base image layers, it caches them. Rebuild with a new Node.js version? Cached. Pull a new &lt;code&gt;ubuntu:24.04&lt;/code&gt;? Cached. Run &lt;code&gt;npm install&lt;/code&gt; after updating &lt;code&gt;package.json&lt;/code&gt;? Every intermediate layer: cached.&lt;/p&gt;

&lt;p&gt;Over weeks of active development across 10+ services, all building on the same server, you accumulate layers on top of layers, most of them orphaned by subsequent builds, none of them cleaned up automatically.&lt;/p&gt;

&lt;p&gt;This is exactly what happened here. &lt;strong&gt;29GB of silent accumulation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deeper Problem: Your Server Is Not a Build Machine
&lt;/h2&gt;

&lt;p&gt;When you build Docker images directly on the server that runs them, you are asking one machine to do two fundamentally different jobs simultaneously:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Job 1: Build environment&lt;/strong&gt;, Compile code, resolve dependencies, execute multi-stage builds, cache intermediate layers, pull base images from registries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Job 2: Runtime environment&lt;/strong&gt;, Run containers reliably, serve real traffic, maintain uptime, stay lean and predictable.&lt;/p&gt;

&lt;p&gt;These two jobs have opposing requirements.&lt;/p&gt;

&lt;p&gt;A build environment &lt;em&gt;benefits&lt;/em&gt; from cache, the more it stores, the faster subsequent builds are. A runtime environment &lt;em&gt;suffers&lt;/em&gt; from cache, it's dead weight that grows without bound and competes with your running services for the disk space they need to function.&lt;/p&gt;

&lt;p&gt;Putting both on the same machine is a design smell. It works, until it doesn't. And when it breaks, it breaks at 100% disk utilisation, which means &lt;em&gt;everything&lt;/em&gt; on that machine is now at risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Senior Move: Eliminate the Source, Not Just the Symptom
&lt;/h2&gt;

&lt;p&gt;The junior response to this situation is: &lt;em&gt;"Run &lt;code&gt;docker builder prune&lt;/code&gt; and move on."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That would have recovered the 29GB. But three weeks later, the same builds would have filled it back up. You'd be doing this every month. Reactive. Whack-a-mole.&lt;/p&gt;

&lt;p&gt;The senior response is: &lt;em&gt;"Why is this cache here in the first place, and how do I make sure it can never accumulate to this scale again?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer: &lt;strong&gt;move the build off the server entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For one of our Next.js services, I set up a GitHub Actions pipeline that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Builds the Docker image in the CI runner (GitHub's infrastructure, not yours)&lt;/li&gt;
&lt;li&gt;Pushes the built image to GHCR (GitHub Container Registry)&lt;/li&gt;
&lt;li&gt;SSHes into the droplet and runs &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The server never runs &lt;code&gt;docker build&lt;/code&gt;. It only ever runs &lt;code&gt;docker pull&lt;/code&gt; and &lt;code&gt;docker run&lt;/code&gt;. It is a &lt;strong&gt;runtime environment&lt;/strong&gt; again, which is exactly what it should be.&lt;/p&gt;

&lt;p&gt;The docker-compose.yml reflects this cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/your-org/your-app: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;web-app&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4700:4700"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json-file"&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-verbose"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--tries=1"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--spider"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:4700"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;build:&lt;/code&gt; key. No &lt;code&gt;context:&lt;/code&gt;. No Dockerfile reference. The server simply pulls a pre-built, verified image from the registry and runs it. The build machine (GitHub Actions runner) does the heavy lifting and discards its own cache after each job. &lt;strong&gt;The server's disk never sees a single build layer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cleanup — Surgical, Not Nuclear
&lt;/h2&gt;

&lt;p&gt;Once I had the architectural change in place, I cleaned up what had accumulated. The key here was being deliberate about what I removed and what I left alone. There are over a dozen services running on this server. The wrong command would have meant downtime.&lt;/p&gt;

&lt;p&gt;Here's the actual order of operations:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Build cache the biggest win, zero risk
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker builder prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes &lt;strong&gt;only&lt;/strong&gt; the build cache. It touches nothing else, no running containers, no images, no volumes. Every service kept running. Freed: &lt;strong&gt;29.22GB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Worth understanding: the next time each remaining server-built service rebuilds, it will be slightly slower because the cache is gone. That's the only consequence. After the first post-prune build, cache starts accumulating again and speed returns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Journal logs the second biggest win
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--vacuum-time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The journald logs had grown to 4.0GB. This command keeps the last 3 days and removes everything older. No service is affected logs are still being written, you're just trimming history.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The old local image the obvious orphan
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker rmi your-app-local:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker system df -v&lt;/code&gt; showed a locally-built image consuming 1.554GB with &lt;strong&gt;0 running containers&lt;/strong&gt;. This was the artifact from the old way, back when the server was still building the image locally. It was superseded the moment the pipeline took over and GHCR became the source of truth. Dead weight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Anonymous volumes, verify before you delete
&lt;/h3&gt;

&lt;p&gt;Two anonymous volumes had &lt;code&gt;0 LINKS&lt;/code&gt;, meaning no container was referencing them. Before removing them, I inspected both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect &amp;lt;volume-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both came back with &lt;code&gt;"Containers": {}&lt;/code&gt;. Orphaned. Removed. Another ~4GB recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did Not Touch
&lt;/h2&gt;

&lt;p&gt;This is equally important.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker system prune -a&lt;/code&gt;, &lt;strong&gt;not run.&lt;/strong&gt; The &lt;code&gt;-a&lt;/code&gt; flag removes ALL images not currently attached to a running container. Several services on this server are temporarily stopped but need their images intact for restart. This command would have deleted them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker volume prune&lt;/code&gt;, &lt;strong&gt;not run blindly.&lt;/strong&gt; I inspected volumes individually first. One wrong deletion here and you're restoring a database from backup at 11pm.&lt;/li&gt;
&lt;li&gt;Any running container, &lt;strong&gt;untouched throughout.&lt;/strong&gt; Before and after, all services remained up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The discipline of knowing what &lt;em&gt;not&lt;/em&gt; to run matters as much as knowing what to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cleanup action&lt;/th&gt;
&lt;th&gt;Space recovered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build cache (&lt;code&gt;docker builder prune -f&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.22GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Journal logs (&lt;code&gt;journalctl --vacuum-time=3d&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~4.0GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old local image (superseded by pipeline)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.554GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orphaned anonymous volumes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~4.1GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~38GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Disk utilisation dropped from &lt;strong&gt;100%&lt;/strong&gt; to roughly &lt;strong&gt;75%&lt;/strong&gt;, and more critically, the architectural change means the build-cache portion can never grow back to that scale for the migrated service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going Forward: Keep It From Happening Again
&lt;/h2&gt;

&lt;p&gt;For services still building on the server (while I migrate them to pipeline builds), a weekly cron prevents silent accumulation:&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;# crontab -e&lt;/span&gt;
&lt;span class="c"&gt;# Prune build cache older than 7 days, every Sunday at 3am&lt;/span&gt;
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; 0 docker builder prune &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="s2"&gt;"until=168h"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/docker-prune.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--filter "until=168h"&lt;/code&gt; flag is the important detail here, it removes cache older than 7 days but preserves recent cache, so the next build isn't cold. It's maintenance, not surgery.&lt;/p&gt;

&lt;p&gt;The real fix, though, is finishing the migration. Every service that moves to pipeline builds is a service whose build artefacts never touch the production server again. That's the direction everything is heading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;If there's one principle to extract from all of this, it's this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A production server's only job is to run containers. Any server that is also building containers is doing two jobs, and eventually, those jobs will conflict.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Build cache is invisible until it's not. It accumulates quietly, efficiently, and with the best of intentions, making your builds faster. But on a shared production server, it's borrowing disk space that belongs to your running services, and it will keep borrowing until there's nothing left.&lt;/p&gt;

&lt;p&gt;The fix isn't a cleanup script on a cron job. It's a CI/CD pipeline that builds in an ephemeral environment, pushes to a registry, and lets your server do the one thing it's actually there to do.&lt;/p&gt;

&lt;p&gt;Diagnose first. Operate surgically. Fix the root cause, not the symptom. And never let your server be its own build machine.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this useful, I create contents about DevOps, infrastructure, and backend engineering on &lt;a href="https://www.youtube.com/@TechPrane" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;, &lt;a href="https://saintvandora.hashnode.dev" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt; and &lt;a href="https://dev.to/saint_vandora"&gt;Dev.to&lt;/a&gt;. Follow along if this is the kind of problem-solving you want more of.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>infrastructure</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Spent hours trying to auto-post from Hashnode to Dev.to.

RSS? Blocked.
GraphQL API? Now paid.
Proxy services? Also blocked.

I documented every dead end + the fix that actually works by building a GitHub Actions workflow that syncs Hashnode to Dev.to</title>
      <dc:creator>FOLASAYO SAMUEL OLAYEMI</dc:creator>
      <pubDate>Sun, 07 Jun 2026 12:24:36 +0000</pubDate>
      <link>https://dev.to/saint_vandora/spent-hours-trying-to-auto-post-from-hashnode-to-devto-rss-blocked-graphql-api-now-paid-36im</link>
      <guid>https://dev.to/saint_vandora/spent-hours-trying-to-auto-post-from-hashnode-to-devto-rss-blocked-graphql-api-now-paid-36im</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-auto-sync-your-hashnode-blog-to-devto-using-github-actions-2026-guide-2b6b" class="crayons-story__hidden-navigation-link"&gt;How to Auto-Sync Your Hashnode Blog to Dev.to Using GitHub Actions (2026 Guide)&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-3840888" 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-auto-sync-your-hashnode-blog-to-devto-using-github-actions-2026-guide-2b6b" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 7&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-auto-sync-your-hashnode-blog-to-devto-using-github-actions-2026-guide-2b6b" id="article-link-3840888"&gt;
          How to Auto-Sync Your Hashnode Blog to Dev.to Using GitHub Actions (2026 Guide)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&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;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/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&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-auto-sync-your-hashnode-blog-to-devto-using-github-actions-2026-guide-2b6b" 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-auto-sync-your-hashnode-blog-to-devto-using-github-actions-2026-guide-2b6b#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;
            5 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>githubactions</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
