{
    "version": "https://jsonfeed.org/version/1",
    "title": "Nemanja Mitic",
    "home_page_url": "https://docker.nemanjamitic.com",
    "feed_url": "https://docker.nemanjamitic.com/api/feed.json",
    "description": "I am Nemanja, a full stack developer",
    "icon": "https://docker.nemanjamitic.com/images/favicons/favicon-32x32.png",
    "author": {
        "name": "Nemanja Mitic",
        "url": "https://docker.nemanjamitic.com/about/"
    },
    "items": [
        {
            "id": "https://docker.nemanjamitic.com/blog/2026-04-07-bash-backup-script/",
            "content_html": "<h2 id=\"introduction\">Introduction</h2>\n<p>This article walks through the thought process behind designing a minimalistic backup script in Bash. While there are robust and sophisticated backup solutions (such as <a href=\"https://github.com/nicotsx/zerobyte\">zerobyte</a> or <a href=\"https://github.com/PlakarKorp/plakar\">plakar</a>), the goal here is to build something simple and minimal. It also serves as a practical exercise for improving both design thinking and Bash scripting skills.</p>\n<p>We won’t start entirely from scratch. Instead, we’ll use the existing <a href=\"https://github.com/todiadiyatmo/bash-backup-rotation-script\">todiadiyatmo/bash-backup-rotation-script</a> as a starting point and adapt and iterate it to fit our use case.</p>\n<p>As a sample application, we’ll use the latest MyBB forum running on PHP and MySQL, based on <a href=\"https://github.com/nemanjam/mybb-docker\">nemanjam/mybb-docker</a>, deployed with Docker.</p>\n<h2 id=\"requirements\">Requirements</h2>\n<p>Let’s start by clearly defining the requirements the script should fulfill so we can address them properly:</p>\n<ul>\n<li>It should back up both the database and multiple, arbitrary file assets.</li>\n<li>It should dump a MySQL database running inside a Docker container.</li>\n<li>It should assume and enforce a predefined folder structure for both the application and the backups.</li>\n<li>It should retain multiple, configurable daily, weekly, and monthly copies (as in the original script).</li>\n<li>It should support both local and remote backups.</li>\n</ul>\n<p>These core requirements are enough to get us started.</p>\n<h2 id=\"structure\">Structure</h2>\n<p>At the beginning, we need to make some core decisions about how to structure the code that creates and manages backups, as well as how to organize the folder structure for the application code, backup scripts, and backup files.</p>\n<p><strong>Note:</strong> The terms “local” and “remote” are used <strong>relative to the server</strong> where the application being backed up is running. “Local” refers to the server’s filesystem, while “remote” refers to machines where backup copies are stored permanently. These are often devices (such as a laptop, Raspberry Pi, or home server) within a local network, so don’t be confused by the terminology.</p>\n<h3 id=\"local-and-remote-scripts\">Local and remote scripts</h3>\n<p>We prefer having local backups that can be quickly restored without needing to fetch and transfer data from a remote machine. At the same time, we also want true remote backups - synced copies stored on one or more external machines, since we treat our server instance as disposable.</p>\n<p>There are two main approaches to this, depending on whether the primary backup script is stored and scheduled locally or on a remote machine:</p>\n<ol>\n<li>A local backup script that creates backups and a separate script syncs them to remote machines.</li>\n<li>Remote machines independently connect over SSH, send and execute the backup script on the server, and download the resulting backup.</li>\n</ol>\n<p>The first approach is simpler and easier to understand, so we’ll go with that. It also provides a solid foundation if we decide to extend the system later and implement the second approach as well.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Main, local backup script</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backup-files-and-mysql.sh</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Remote sync script</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backup-rsync-local.sh</span></span></code></pre>\n<p>However, both approaches rely on a clear, predefined folder structure, which should be explicitly validated when the script runs.</p>\n<h3 id=\"folder-structure\">Folder structure</h3>\n<p>Bash scripts rely completely on relative paths, which means the script code and the surrounding folder structure are tightly coupled. A simple and understandable structure is a prerequisite for clean and reliable code.</p>\n<h4 id=\"local-folder-structure\">Local folder structure</h4>\n<p>Below is the expected folder structure for both the server and local backups. In this context, “local” means local to the running application itself, on the same machine and file system. This backup repository serves as <strong>the source of truth</strong> for all other synchronized, remote backup copies.</p>\n<p>Here, <code>mybb/</code> is the application root directory, containing both the application files and the backup. Accordingly, <code>mybb/backup/</code> is the backup directory, which includes the backup Bash scripts (<code>mybb/backup/scripts/</code>) and the backup data (<code>mybb/backup/data/</code>). The generated <code>.zip</code> archive contains a <code>mysql_database/</code> folder for the MySQL dump, while adjacent asset folders retain their original names.</p>\n<p>This folder structure is mandatory and fixed, as <strong>all file paths</strong> in the <code>backup-files-and-mysql.sh</code> script are defined <strong>relative to the script location</strong> (<code>mybb/backup/scripts/</code>).</p>\n<p>Another useful detail is that the application files in <code>mybb/</code> are versioned, including the <code>backup-files-and-mysql.sh</code> script. Since we don’t use a <code>.env</code> file for configuration, the production server contains an unversioned copy named <code>backup-files-and-mysql-run.sh</code>, which includes both the actual variables and executable code. This approach simplifies <code>git pull</code> operations and repository updates. Naturally, all backup data in <code>mybb/backup/data/</code> is excluded from version control via <code>.gitignore</code>.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\">#  Local (server) backup folder structure:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># mybb/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ├─ backup/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │   ├─ scripts/                             - backup scripts</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │   │  ├─ backup-files-and-mysql.sh         - versioned</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │   │  └─ backup-files-and-mysql-run.sh     - current script</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │   └─ data/                                - backups data</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      ├─ mybb_files_and_mysql-daily-2026-01-20.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      │  ├─ inc/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      │  ├─ images/custom/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      │  └─ mysql_database/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      │     └─ mybb.sql</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      ├─ mybb_files_and_mysql-daily-2026-01-19.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      ├─ mybb_files_and_mysql-weekly-2026-01-14.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │      └─ mybb_files_and_mysql-monthly-2026-01-01.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ├── data                                    - Docker volumes</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │   ├── mybb-data                           - PHP forum files</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># │   └── mysql-data                          - database data</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ├── docker-compose.yml                      - containers definitions</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># |</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># |</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ├── .gitignore</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># └── README.md</span></span></code></pre>\n<h4 id=\"remote-synced-folder-structure\">Remote (synced) folder structure</h4>\n<p>This is the synchronized backup repository, which is considered remote from the server’s perspective. In most cases, it resides on one of our local machines where backups are stored.</p>\n<p>As a synchronized mirror, its folder structure is identical, with one important distinction: it only contains the <code>mybb/backup/</code> directory and does not include any application files, only the backups themselves. Additionally, this repository is not versioned.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\">#  Remote (synced) backup folder structure:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># mybb/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># └─ backup/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#    ├─ scripts/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#    │  └─ backup-rsync-local.sh              - current script</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#    └─ data/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       ├─ .gitkeep</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       ├─ mybb_files_and_mysql-daily-2026-01-20.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       │  ├─ inc/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       │  ├─ images/custom/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       │  └─ mysql_database/</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       │     └─ mybb.sql</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       ├─ mybb_files_and_mysql-daily-2026-01-19.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       ├─ mybb_files_and_mysql-weekly-2026-01-14.zip</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">#       └─ mybb_files_and_mysql-monthly-2026-01-01.zip</span></span></code></pre>\n<h2 id=\"local-backup-script\">Local backup script</h2>\n<p>This is the main script that creates a backup on the server’s local filesystem. It dumps the MySQL database running in Docker into a plain UTF-8 <code>.sql</code> file and also backs up predefined application files and folders. A temporary staging folder is used to create a well-structured <code>.zip</code> archive.</p>\n<p>Let’s walk through the main script responsible for creating backups of MySQL database and arbitrary application assets, section by section.</p>\n<p>Entire script: <a href=\"https://github.com/nemanjam/bash-backup/blob/main/backup-files-and-mysql.sh\">https://github.com/nemanjam/bash-backup/blob/main/backup-files-and-mysql.sh</a></p>\n<h3 id=\"configurable-variables\">Configurable variables</h3>\n<p>These are the real variables that can be freely adjusted to fit a specific application and use case. Normally, they would be defined in a <code>.env</code> file, but for simplicity, they are hardcoded directly in the Bash script. Modifying these values does not require changing the script’s code.</p>\n<p>The variables are fairly self-explanatory:</p>\n<ul>\n<li><code>DB_*</code> - variables used for connecting to the MySQL database instance we want to back up.</li>\n<li><code>LOCAL_BACKUP_DIR</code> - the directory where backup files are stored.</li>\n<li><code>SRC_CODE_DIRS</code> - an associative array listing the files and directories to include in the backup.</li>\n<li><code>BACKUP_RETENTION_*</code> - the number of daily, weekly, and monthly backups to retain.</li>\n<li><code>MAX_RETENTION</code> - the upper limit for any <code>BACKUP_RETENTION_*</code> value.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Configuration ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># MySQL credentials</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DB_CONTAINER_NAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mybb-database\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DB_NAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mybb\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DB_USER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mybbuser\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DB_PASS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"password\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Note: all commands run from script dir, NEVER call cd, for relative paths to work</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Dirs paths</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Local folder is root, all other paths are relative to it</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># script located at ~/traefik-proxy/apps/mybb/backup/scripts</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOCAL_BACKUP_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"../data\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># File or directory</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Relative to script dir, ../../ returns to: apps/mybb/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">declare</span><span style=\"color:#79B8FF\"> -A</span><span style=\"color:#E1E4E8\"> SRC_CODE_DIRS</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    [</span><span style=\"color:#B392F0\">\"inc\"</span><span style=\"color:#E1E4E8\">]=</span><span style=\"color:#9ECBFF\">\"../../data/mybb-data/inc/config.php\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    [</span><span style=\"color:#B392F0\">\"images/custom\"</span><span style=\"color:#E1E4E8\">]=</span><span style=\"color:#9ECBFF\">\"../../data/mybb-data/images/custom\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Retention</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">MAX_RETENTION</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">6</span><span style=\"color:#6A737D\"> # 6 months for monthly backups</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKUP_RETENTION_DAILY</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">3</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKUP_RETENTION_WEEKLY</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">2</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKUP_RETENTION_MONTHLY</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">6</span></span></code></pre>\n<h3 id=\"logging-variables\">Logging variables</h3>\n<p>These are additional configurable variables used specifically to control logging behavior. Since the backup script runs periodically via cron, proper logging is essential for monitoring, validation, and debugging. The last thing we want is a incorrect or misconfigured script silently producing invalid and unusable backups for months.</p>\n<p>The variables are as follows:</p>\n<ul>\n<li><code>LOG_TO_FILE</code> - a boolean that determines whether logs are written to a file or output to the terminal.</li>\n<li><code>LOG_FILE</code> - the path to the log file.</li>\n<li><code>LOG_MAX_SIZE_MB</code> and <code>LOG_KEEP_SIZE_MB</code> - used to prevent unlimited log file growth. <code>LOG_MAX_SIZE_MB</code> a float defining the maximum file size (in MB), after which the log is truncated, while <code>LOG_KEEP_SIZE_MB</code> defines the size to retain after truncation.</li>\n<li><code>LOG_TIMEZONE</code> - the time zone used for log timestamps.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Logging vars ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Enable only when running from cron</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Cron has no TTY, interactive shell does</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_TO_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">false</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$PS1</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ] &#x26;&#x26; LOG_TO_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">true</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log file</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"./log-backup-files-and-mysql.txt\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log size limits (MB, float allowed)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_MAX_SIZE_MB</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1.0</span><span style=\"color:#6A737D\">   # truncate when log exceeds this</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_KEEP_SIZE_MB</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">0.5</span><span style=\"color:#6A737D\">  # keep last N MB after truncation</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Timezone for log timestamps</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_TIMEZONE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Europe/Belgrade\"</span></span></code></pre>\n<h3 id=\"constants\">Constants</h3>\n<p>These are constant global variables, defined in a single place and reused throughout the script. Unlike configuration variables, they are not meant to be modified, as they are tightly coupled with the script’s logic. Changing them requires corresponding updates to the code.</p>\n<ul>\n<li><code>MYSQL_ZIP_DIR_NAME</code> and <code>FILES_ZIP_DIR_NAME</code> - directory names used inside the archive.</li>\n<li><code>ZIP_PREFIX</code> - prefix for backup archive filenames.</li>\n<li><code>FREQ_PLACEHOLDER</code> - placeholder string to be replaced with the actual retention frequency in archive names.</li>\n<li><code>DATE</code> - date string included in the archive filename.</li>\n<li><code>DAY_OF_*</code> - numeric values (e.g., day of week/month) included in archive names.</li>\n<li><code>BACKUP_*</code> - boolean flags derived from <code>BACKUP_RETENTION_*</code> variables.</li>\n<li><code>SCRIPT_DIR</code> - absolute path of the current script, intended for resolving relative paths (currently unused).</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Constants ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Zip vars</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Both inside zip</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">MYSQL_ZIP_DIR_NAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mysql_database\"</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">FILES_ZIP_DIR_NAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"source_code\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Must match backup-rsync-local.sh</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">ZIP_PREFIX</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mybb_files_and_mysql\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">FREQ_PLACEHOLDER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">'frequency'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DATE</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">date</span><span style=\"color:#9ECBFF\"> +\"%Y-%m-%d\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">ZIP_PATH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$ZIP_PREFIX</span><span style=\"color:#9ECBFF\">-</span><span style=\"color:#E1E4E8\">$FREQ_PLACEHOLDER</span><span style=\"color:#9ECBFF\">-</span><span style=\"color:#E1E4E8\">$DATE</span><span style=\"color:#9ECBFF\">.zip\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Current day and weekday</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DAY_OF_MONTH</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$((</span><span style=\"color:#B392F0\">10#$(date</span><span style=\"color:#9ECBFF\"> +%d</span><span style=\"color:#E1E4E8\">))) </span><span style=\"color:#6A737D\"># Force decimal, avoid bash octal bug on 08/09</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DAY_OF_WEEK</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$((</span><span style=\"color:#B392F0\">10#$(date</span><span style=\"color:#9ECBFF\"> +%u</span><span style=\"color:#E1E4E8\">))) </span><span style=\"color:#6A737D\"># 1=Monday … 7=Sunday</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Must do it like this for booleans</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKUP_DAILY</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$([[ $BACKUP_RETENTION_DAILY </span><span style=\"color:#F97583\">-gt</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> ]] &#x26;&#x26; </span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#79B8FF\"> echo</span><span style=\"color:#79B8FF\"> false</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKUP_WEEKLY</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$([[ $BACKUP_RETENTION_WEEKLY </span><span style=\"color:#F97583\">-gt</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> ]] &#x26;&#x26; </span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#79B8FF\"> echo</span><span style=\"color:#79B8FF\"> false</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKUP_MONTHLY</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$([[ $BACKUP_RETENTION_MONTHLY </span><span style=\"color:#F97583\">-gt</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> ]] &#x26;&#x26; </span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#79B8FF\"> echo</span><span style=\"color:#79B8FF\"> false</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Script dir absolute path, unused</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># mybb/backup/scripts</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SCRIPT_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"$(</span><span style=\"color:#79B8FF\">cd</span><span style=\"color:#9ECBFF\"> \"$(</span><span style=\"color:#B392F0\">dirname</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#E1E4E8\">BASH_SOURCE</span><span style=\"color:#9ECBFF\">[0]}\")\" &#x26;&#x26; </span><span style=\"color:#79B8FF\">pwd</span><span style=\"color:#9ECBFF\">)\"</span></span></code></pre>\n<h3 id=\"setup-logging\">Setup logging</h3>\n<p>At the top of the script, right below the variable and constant definitions, we conditionally call the <code>setup_logging()</code> function based on the <code>LOG_TO_FILE</code> variable. This function redirects both standard output and error output (e.g., from <code>echo</code> commands) to the <code>LOG_FILE</code>.</p>\n<p>Additionally, it avoids a common pitfall of infinitely growing log files by truncating the file to <code>LOG_KEEP_SIZE_MB</code> whenever it reaches <code>LOG_MAX_SIZE_MB</code>. This ensures that only the most recent log entries are preserved.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Enable logging ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">setup_logging</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> max_size keep_size</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Convert MB -> bytes (rounded down)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    max_size</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_MAX_SIZE_MB</span><span style=\"color:#9ECBFF\"> * 1024 * 1024 / 1\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> bc</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    keep_size</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_KEEP_SIZE_MB</span><span style=\"color:#9ECBFF\"> * 1024 * 1024 / 1\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> bc</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Ensure log file exists (do not truncate)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">!</span><span style=\"color:#F97583\"> -f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        touch</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Truncate log if too big</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> size size_mb</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    size</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">stat</span><span style=\"color:#79B8FF\"> -c%s</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    size_mb</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">awk</span><span style=\"color:#9ECBFF\"> \"BEGIN {printf </span><span style=\"color:#79B8FF\">\\\"</span><span style=\"color:#9ECBFF\">%.2f</span><span style=\"color:#79B8FF\">\\\"</span><span style=\"color:#9ECBFF\">, </span><span style=\"color:#E1E4E8\">$size</span><span style=\"color:#9ECBFF\">/1024/1024}\"</span><span style=\"color:#E1E4E8\">)  </span><span style=\"color:#6A737D\"># convert bytes -> MB</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (( size </span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\"> max_size )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        tail</span><span style=\"color:#79B8FF\"> -c</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$keep_size</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">.tmp\"</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">mv</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">.tmp\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Log truncation message happens before the exec redirection, so it needs its own timestamp</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"$(</span><span style=\"color:#E1E4E8\">TZ</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOG_TIMEZONE</span><span style=\"color:#9ECBFF\">\" </span><span style=\"color:#B392F0\">date</span><span style=\"color:#9ECBFF\"> '+%Y-%m-%d %H:%M:%S') [INFO] Log truncated: original_size=${</span><span style=\"color:#E1E4E8\">size_mb</span><span style=\"color:#9ECBFF\">}MB, max_size=${</span><span style=\"color:#E1E4E8\">LOG_MAX_SIZE_MB</span><span style=\"color:#9ECBFF\">}MB, keep_size=${</span><span style=\"color:#E1E4E8\">LOG_KEEP_SIZE_MB</span><span style=\"color:#9ECBFF\">}MB\"</span><span style=\"color:#F97583\"> >></span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Redirect stdout + stderr to log file with timestamps</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    exec</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\"> >(while </span><span style=\"color:#E1E4E8\">IFS</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> read</span><span style=\"color:#79B8FF\"> -r</span><span style=\"color:#9ECBFF\"> line; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"$(</span><span style=\"color:#E1E4E8\">TZ</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOG_TIMEZONE</span><span style=\"color:#9ECBFF\">\" </span><span style=\"color:#B392F0\">date</span><span style=\"color:#9ECBFF\"> '+%Y-%m-%d %H:%M:%S') </span><span style=\"color:#E1E4E8\">$line</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span><span style=\"color:#F97583\"> >></span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\")</span><span style=\"color:#F97583\"> 2>&#x26;1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Per-run separator — just echo, timestamps added automatically</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"========================================\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Logging started\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Log file: </span><span style=\"color:#E1E4E8\">$LOG_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Max size: ${</span><span style=\"color:#E1E4E8\">LOG_MAX_SIZE_MB</span><span style=\"color:#9ECBFF\">}MB, keep: ${</span><span style=\"color:#E1E4E8\">LOG_KEEP_SIZE_MB</span><span style=\"color:#9ECBFF\">}MB\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"========================================\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOG_TO_FILE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    setup_logging</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span></code></pre>\n<h3 id=\"validate-configuration\">Validate configuration</h3>\n<p>Before executing any other logic, we validate the existence and correctness of the configuration variables and ensure that the environment meets the basic requirements needed to produce a usable and meaningful backup. The following checks are performed:</p>\n<ul>\n<li>The MySQL container is running.</li>\n<li>A connection to the MySQL database inside the container can be established.</li>\n<li>The local backup directory is defined, exists, and is not the root directory (to avoid catastrophic deletion).</li>\n<li>All defined asset paths exist.</li>\n<li>At least one of daily, weekly, or monthly backups is enabled.</li>\n<li>Any temporary backup archive from the previous run is deleted. This also allows the backup to be recreated and overwritten on the same day.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Validate config ------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">is_valid_config</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> non_zero_found</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Validating configuration...\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check that MySQL container is running</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> docker</span><span style=\"color:#9ECBFF\"> inspect</span><span style=\"color:#79B8FF\"> -f</span><span style=\"color:#9ECBFF\"> '{{.State.Running}}'</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$DB_CONTAINER_NAME</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> 2></span><span style=\"color:#9ECBFF\">/dev/null</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#79B8FF\"> -q</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] MySQL container not running or not found: DB_CONTAINER_NAME=</span><span style=\"color:#E1E4E8\">$DB_CONTAINER_NAME</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check MySQL connectivity inside container</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> docker</span><span style=\"color:#9ECBFF\"> exec</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$DB_CONTAINER_NAME</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">        mysql</span><span style=\"color:#79B8FF\"> -u</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$DB_USER</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> -p</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$DB_PASS</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$DB_NAME</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> -e</span><span style=\"color:#9ECBFF\"> \"SELECT 1;\"</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\">/dev/null</span><span style=\"color:#F97583\"> 2>&#x26;1</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] MySQL connection failed: container=</span><span style=\"color:#E1E4E8\">$DB_CONTAINER_NAME</span><span style=\"color:#9ECBFF\"> user=</span><span style=\"color:#E1E4E8\">$DB_USER</span><span style=\"color:#9ECBFF\"> db=</span><span style=\"color:#E1E4E8\">$DB_NAME</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check local backup directory variable is set, dir exists, and is not root</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ] </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">!</span><span style=\"color:#F97583\"> -d</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ] </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> \"/\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Local backup directory invalid: path=</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check source code paths exist (file or directory)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> path </span><span style=\"color:#F97583\">in</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#E1E4E8\">SRC_CODE_DIRS</span><span style=\"color:#9ECBFF\">[</span><span style=\"color:#F97583\">@</span><span style=\"color:#9ECBFF\">]}\"</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">!</span><span style=\"color:#F97583\"> -e</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$path</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Source path missing: path=</span><span style=\"color:#E1E4E8\">$SCRIPT_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$path</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Validate retention values</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> var </span><span style=\"color:#F97583\">in</span><span style=\"color:#9ECBFF\"> BACKUP_RETENTION_DAILY</span><span style=\"color:#9ECBFF\"> BACKUP_RETENTION_WEEKLY</span><span style=\"color:#9ECBFF\"> BACKUP_RETENTION_MONTHLY</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        value</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">var</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">!</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$value</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> =~</span><span style=\"color:#E1E4E8\"> ^[0-9]+$ ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Retention value is not a number: </span><span style=\"color:#E1E4E8\">$var</span><span style=\"color:#9ECBFF\">=</span><span style=\"color:#E1E4E8\">$value</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> (( value </span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\"> MAX_RETENTION )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Retention value too large: </span><span style=\"color:#E1E4E8\">$var</span><span style=\"color:#9ECBFF\">=</span><span style=\"color:#E1E4E8\">$value</span><span style=\"color:#9ECBFF\"> max=</span><span style=\"color:#E1E4E8\">$MAX_RETENTION</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        (( value </span><span style=\"color:#F97583\">></span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> )) &#x26;&#x26; non_zero_found</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (( non_zero_found </span><span style=\"color:#F97583\">==</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] All retention values are zero: daily=</span><span style=\"color:#E1E4E8\">$BACKUP_RETENTION_DAILY</span><span style=\"color:#9ECBFF\"> weekly=</span><span style=\"color:#E1E4E8\">$BACKUP_RETENTION_WEEKLY</span><span style=\"color:#9ECBFF\"> monthly=</span><span style=\"color:#E1E4E8\">$BACKUP_RETENTION_MONTHLY</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Delete existing temp backup file for this day (idempotent, can run on same day)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">-f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        rm</span><span style=\"color:#79B8FF\"> -f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[WARN] Existing temporary backup file deleted: </span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Configuration is valid. Creating backup...\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"backup-logic\">Backup logic</h3>\n<p>The <code>create_backup()</code> function is the core of the entire script. It assembles a MySQL database dump and file assets into a structured archive with well-organized relative paths, while also cleaning up any temporary files created during the process.</p>\n<p>To build the archive with proper relative paths, we use a temporary <code>STAGING_DIR</code> directory. Inside this directory, the database dump is stored under a folder named by the <code>MYSQL_ZIP_DIR_NAME</code> constant, while file assets are grouped under the <code>FILES_ZIP_DIR_NAME</code> directory.</p>\n<p>Using a combination of <code>docker exec</code> and <code>mysqldump</code>, we export the database contents as a plain UTF-8 <code>.sql</code> file and place it into the staging directory. The file is named after the database itself, using the <code>DB_NAME</code> configuration variable.</p>\n<p>We then copy all asset files and directories defined in <code>SRC_CODE_DIRS</code> into the staging directory under the <code>FILES_DIR</code> parent folder. If <code>SRC_CODE_DIRS</code> is empty, the entire <code>FILES_DIR</code> directory is removed from the staging area to avoid unnecessary clutter.</p>\n<p>Next, we create a <code>.zip</code> archive from <code>STAGING_DIR</code> using a subshell to safely change directories without affecting the main script, which would otherwise break relative path handling. Note that the script is intentionally designed to rely exclusively on relative paths. The resulting archive is saved to the <code>ZIP_PATH</code> location.</p>\n<p>Finally, we remove the <code>STAGING_DIR</code> directory regardless of whether archive creation succeeds or fails. This ensures idempotency and prevents leftover temporary files from accumulating.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">create_backup</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Note: use staging dir with relative paths to have nice overview in GUI archive utility</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Local scope</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # staging dir: mybb/backup/data/staging_dir</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # temp db dir: mybb/backup/data/staging_dir/mysql_database</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # working dir: mybb/backup/scripts</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> STAGING_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/staging_dir\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> TEMP_DB_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$MYSQL_ZIP_DIR_NAME</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> FILES_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$FILES_ZIP_DIR_NAME</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Reset staging dir from previous broken state </span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    rm</span><span style=\"color:#79B8FF\"> -rf</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    mkdir</span><span style=\"color:#79B8FF\"> -p</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$TEMP_DB_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#6A737D\">  # Will recreate staging dir</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    mkdir</span><span style=\"color:#79B8FF\"> -p</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#6A737D\">    # Folder to group all source code</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Created staging directory: </span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Created temporary DB directory: </span><span style=\"color:#E1E4E8\">$TEMP_DB_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Created files directory: </span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Dump MySQL as plain UTF-8 .sql</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    docker</span><span style=\"color:#9ECBFF\"> exec</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$DB_CONTAINER_NAME</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> sh</span><span style=\"color:#79B8FF\"> -c</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">        'mysqldump --no-tablespaces -u\"$DB_USER\" -p\"$DB_PASS\" \"$DB_NAME\"'</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        ></span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$TEMP_DB_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$DB_NAME</span><span style=\"color:#9ECBFF\">.sql\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] MySQL database dumped: db_name=</span><span style=\"color:#E1E4E8\">$DB_NAME</span><span style=\"color:#9ECBFF\"> -> path=</span><span style=\"color:#E1E4E8\">$TEMP_DB_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$DB_NAME</span><span style=\"color:#9ECBFF\">.sql\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Copy source code folders grouped into FILES_DIR dir</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> SRC_CODE_DIR </span><span style=\"color:#F97583\">in</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">SRC_CODE_DIRS</span><span style=\"color:#9ECBFF\">[</span><span style=\"color:#F97583\">@</span><span style=\"color:#9ECBFF\">]}\"</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        SRC_CODE_DIR_PATH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">SRC_CODE_DIRS</span><span style=\"color:#9ECBFF\">[</span><span style=\"color:#E1E4E8\">$SRC_CODE_DIR</span><span style=\"color:#9ECBFF\">]}\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        cp</span><span style=\"color:#79B8FF\"> -a</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$SRC_CODE_DIR_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">/\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Added to staging: </span><span style=\"color:#E1E4E8\">$SRC_CODE_DIR_PATH</span><span style=\"color:#9ECBFF\"> -> </span><span style=\"color:#E1E4E8\">$FILES_ZIP_DIR_NAME</span><span style=\"color:#9ECBFF\">/\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Remove FILES_DIR if empty</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">-d</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ] &#x26;&#x26; [ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"$(</span><span style=\"color:#B392F0\">ls</span><span style=\"color:#79B8FF\"> -A</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">\")\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        rm</span><span style=\"color:#79B8FF\"> -rf</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Removed empty files directory: </span><span style=\"color:#E1E4E8\">$FILES_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Create zip with clean relative paths</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ( ... ) - subshell, cd wont affect working dir of the main script</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    (</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        cd</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Failed to cd into staging directory: </span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # There was cd in subshell</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Adjust zip path relative to staging_dir</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        zip</span><span style=\"color:#79B8FF\"> -r</span><span style=\"color:#9ECBFF\"> \"../</span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> .</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ) </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Zip creation failed: </span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        rm</span><span style=\"color:#79B8FF\"> -rf</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Created zip archive: </span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Cleanup</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    rm</span><span style=\"color:#79B8FF\"> -rf</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Removed staging directory: </span><span style=\"color:#E1E4E8\">$STAGING_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Backup file created successfully: </span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>The <code>create_retention_copies()</code> function manages retention by creating time-based copies (daily, weekly, monthly) of a freshly generated backup file. The original backup file path (<code>ZIP_PATH</code>) includes a placeholder string (<code>frequency</code>), defined by the <code>FREQ_PLACEHOLDER</code> constant.</p>\n<p>For each retention option, the function evaluates the current date (e.g., Sunday for weekly, the first day of the month for monthly) and checks whether the corresponding <code>BACKUP_*</code> variable is enabled. If the conditions are satisfied, the original backup is copied and renamed by replacing the placeholder with the appropriate frequency.</p>\n<p>To ensure idempotency, the function checks whether a retention copy for the current day already exists. If it does, it is removed before creating a new one, allowing the script to be safely re-run on the same day.</p>\n<p>Finally, the temporary backup file with the placeholder name is deleted, as it is no longer needed after the retention copies are created.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">create_retention_copies</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> IS_WEEKLY</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(( </span><span style=\"color:#B392F0\">DAY_OF_WEEK</span><span style=\"color:#9ECBFF\"> ==</span><span style=\"color:#79B8FF\"> 7</span><span style=\"color:#E1E4E8\"> )) </span><span style=\"color:#6A737D\"># Sunday</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> IS_MONTHLY</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(( </span><span style=\"color:#B392F0\">DAY_OF_MONTH</span><span style=\"color:#9ECBFF\"> ==</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\"> )) </span><span style=\"color:#6A737D\"># First day of month</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">!</span><span style=\"color:#F97583\"> -f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Backup file does not exist: </span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> FREQ </span><span style=\"color:#F97583\">in</span><span style=\"color:#9ECBFF\"> daily</span><span style=\"color:#9ECBFF\"> weekly</span><span style=\"color:#9ECBFF\"> monthly</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        case</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FREQ</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> in</span></span>\n<span class=\"line\"><span style=\"color:#DBEDFF\">            daily</span><span style=\"color:#F97583\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                [[ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$BACKUP_DAILY</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ==</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\"> ]] </span><span style=\"color:#F97583\">||</span><span style=\"color:#F97583\"> continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                ;;</span></span>\n<span class=\"line\"><span style=\"color:#DBEDFF\">            weekly</span><span style=\"color:#F97583\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                [[ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$IS_WEEKLY</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> -eq</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$BACKUP_WEEKLY</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ==</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\"> ]] </span><span style=\"color:#F97583\">||</span><span style=\"color:#F97583\"> continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                ;;</span></span>\n<span class=\"line\"><span style=\"color:#DBEDFF\">            monthly</span><span style=\"color:#F97583\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                [[ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$IS_MONTHLY</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> -eq</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$BACKUP_MONTHLY</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ==</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\"> ]] </span><span style=\"color:#F97583\">||</span><span style=\"color:#F97583\"> continue</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                ;;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        esac</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Placeholder 'frequency' string replacement</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        TARGET_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">ZIP_PATH</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">$FREQ_PLACEHOLDER</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">$FREQ</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Delete existing backup for this frequency (idempotent, can run on same day)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">-f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$TARGET_FILE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            rm</span><span style=\"color:#79B8FF\"> -f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$TARGET_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"[WARN] Existing </span><span style=\"color:#E1E4E8\">$FREQ</span><span style=\"color:#9ECBFF\"> backup removed: </span><span style=\"color:#E1E4E8\">$TARGET_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        cp</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$TARGET_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] </span><span style=\"color:#E1E4E8\">$FREQ</span><span style=\"color:#9ECBFF\"> backup copied successfully: </span><span style=\"color:#E1E4E8\">$TARGET_FILE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    rm</span><span style=\"color:#79B8FF\"> -f</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Removed temporary backup file: </span><span style=\"color:#E1E4E8\">$ZIP_PATH</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>We don’t want to accumulate an unlimited number of backup copies; instead, we delete outdated ones according to the retention limits defined by the <code>BACKUP_RETENTION_*</code> variables.</p>\n<p>The <code>prune_old_backups()</code> function enforces these retention limits by removing older backups for each frequency (daily, weekly, monthly).</p>\n<p>Based on the <code>BACKUP_RETENTION_*</code> variables, we dynamically calculate the <code>RETENTION</code> integer value for each frequency. If it is zero or unset, we exit early from the loop.</p>\n<p>We then list the contents of the <code>LOCAL_BACKUP_DIR</code> and delete outdated copies by processing the output of the <code>ls</code> command through a pipeline:</p>\n<ul>\n<li>We filter out files that do not contain values from the <code>ZIP_PREFIX</code> and <code>FREQ</code> variables.</li>\n<li>We skip the first <code>RETENTION</code> lines, which effectively keeps the <code>RETENTION</code> most recent backups.</li>\n<li>Using <code>xargs</code> and <code>rm -R</code>, we delete all remaining filenames line by line, ignoring any error messages to keep logs clean.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">prune_old_backups</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> FREQ </span><span style=\"color:#F97583\">in</span><span style=\"color:#9ECBFF\"> daily</span><span style=\"color:#9ECBFF\"> weekly</span><span style=\"color:#9ECBFF\"> monthly</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Determine retention variable dynamically</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        RETENTION_VAR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"BACKUP_RETENTION_${</span><span style=\"color:#E1E4E8\">FREQ</span><span style=\"color:#9ECBFF\">^^}\"</span><span style=\"color:#6A737D\">  # uppercase: daily -> DAILY</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        RETENTION</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">RETENTION_VAR</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Skip if retention is zero or unset</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        [[ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$RETENTION</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$RETENTION</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> -le</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> ]] &#x26;&#x26; </span><span style=\"color:#F97583\">continue</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Find old backups and delete them</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        ls</span><span style=\"color:#79B8FF\"> -t</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$ZIP_PREFIX</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FREQ</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            |</span><span style=\"color:#B392F0\"> sed</span><span style=\"color:#79B8FF\"> -e</span><span style=\"color:#9ECBFF\"> 1,\"</span><span style=\"color:#E1E4E8\">$RETENTION</span><span style=\"color:#9ECBFF\">\"d</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            |</span><span style=\"color:#B392F0\"> xargs</span><span style=\"color:#79B8FF\"> -d</span><span style=\"color:#9ECBFF\"> '\\n'</span><span style=\"color:#79B8FF\"> -I</span><span style=\"color:#E1E4E8\">{} rm -R </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/{}\"</span><span style=\"color:#F97583\"> ></span><span style=\"color:#E1E4E8\"> /dev/null </span><span style=\"color:#F97583\">2>&#x26;1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Pruned </span><span style=\"color:#E1E4E8\">$FREQ</span><span style=\"color:#9ECBFF\"> backups, keeping last </span><span style=\"color:#E1E4E8\">$RETENTION</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"main-invocation\">Main invocation</h3>\n<p>Finally, we invoke the functions defined above.</p>\n<p>First, we ensure that all required configuration is correct. If validation fails, the script prints an error message to stderr and immediately exits with a non-zero status, preventing any further execution.</p>\n<p>If the configuration is valid, the script continues by creating a backup archive, then generating additional retention copies, and finally removing old backups.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Main script ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> is_valid_config</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Configuration validation failed. Aborting backup.\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">create_backup</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">create_retention_copies</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">prune_old_backups</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Backup completed successfully.\"</span></span></code></pre>\n<p>Below is an example log entry from a successful run when creating a backup:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"txt\"><code><span class=\"line\"><span>2026-04-07 00:30:01 ========================================</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Logging started</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Log file: ./log-backup-files-and-mysql.txt</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Max size: 1.0MB, keep: 0.5MB</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 ========================================</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 </span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Validating configuration...</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Configuration is valid. Creating backup...</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Created staging directory: ../data/staging_dir</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Created temporary DB directory: ../data/staging_dir/mysql_database</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 [INFO] Created files directory: ../data/staging_dir/source_code</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:01 mysqldump: [Warning] Using a password on the command line interface can be insecure.</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] MySQL database dumped: db_name=mybb -> path=../data/staging_dir/mysql_database/mybb.sql</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Added to staging: ../../data/mybb-data/images/custom -> source_code/</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Added to staging: ../../data/mybb-data/inc/config.php -> source_code/</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: mysql_database/ (stored 0%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: mysql_database/mybb.sql (deflated 83%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: source_code/ (stored 0%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: source_code/config.php (deflated 62%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: source_code/custom/ (stored 0%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: source_code/custom/logo-blue-153x75.png (stored 0%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02   adding: source_code/custom/logo-blue-588x288.png (deflated 0%)</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Created zip archive: ../data/mybb_files_and_mysql-frequency-2026-04-07.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Removed staging directory: ../data/staging_dir</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Backup file created successfully: ../data/mybb_files_and_mysql-frequency-2026-04-07.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] daily backup copied successfully: ../data/mybb_files_and_mysql-daily-2026-04-07.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Removed temporary backup file: ../data/mybb_files_and_mysql-frequency-2026-04-07.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Pruned daily backups, keeping last 3</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Pruned weekly backups, keeping last 2</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Pruned monthly backups, keeping last 6</span></span>\n<span class=\"line\"><span>2026-04-07 00:30:02 [INFO] Backup completed successfully.</span></span></code></pre>\n<h2 id=\"remote-syncing-script\">Remote syncing script</h2>\n<p>Besides the main backup script running directly on the server, we use an additional script that replicates the original backup on remote machines.</p>\n<p>This script synchronizes the backups created on the server (which serves as the source of truth) to remote machines used for storing backup copies. It connects to the server via SSH, validates both the backup and local folder structure, and finally synchronizes the data using the <code>rsync</code> command.</p>\n<p>Entire script: <a href=\"https://github.com/nemanjam/bash-backup/blob/main/backup-rsync-local.sh\">https://github.com/nemanjam/bash-backup/blob/main/backup-rsync-local.sh</a></p>\n<h3 id=\"configurable-variables-1\">Configurable variables</h3>\n<p>Similarly to the local scripts, these are configurable variables that would typically be defined in a <code>.env</code> file. They are used to configure the synchronization script.</p>\n<ul>\n<li><code>REMOTE_HOST</code> - the server host used for the SSH and <code>rsync</code> connections.</li>\n<li><code>REMOTE_BACKUP_DIR</code> - the <strong>absolute path</strong> (avoid shell expansion) to the backup directory on the server (source of truth).</li>\n<li><code>LOCAL_BACKUP_DIR</code> - the <strong>relative path</strong> to the local directory where backups are synchronized.</li>\n<li><code>MIN_BACKUP_SIZE_MB</code> - a human-readable float value (in MB) representing the minimum valid backup size.</li>\n<li><code>MIN_BACKUP_SIZE_BYTES</code> - the equivalent integer value in bytes, used for actual validation and computations.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Configuration ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REMOTE_HOST</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"arm2\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Full, absolute path - can't use ~/, used both locally and remote with ssh/rsync</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REMOTE_BACKUP_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"/home/ubuntu/traefik-proxy/apps/mybb/backup/data\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Note: all commands run from script dir, NEVER call cd, for relative LOCAL paths to work</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOCAL_BACKUP_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"../data\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Minimum valid backup size, ZIP size, compressed</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Only db for blank forum, zip=158.2 KiB</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Float</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">MIN_BACKUP_SIZE_MB</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">0.1</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Integer</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">MIN_BACKUP_SIZE_BYTES</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$MIN_BACKUP_SIZE_MB</span><span style=\"color:#9ECBFF\"> * 1024 * 1024 / 1\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> bc</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#6A737D\"># rounded to integer</span></span></code></pre>\n<h3 id=\"constants-1\">Constants</h3>\n<p>The only constant used is <code>ZIP_PREFIX</code> and it <strong>must</strong> match the one used in backup creation script <code>backup-files-and-mysql.sh</code>.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Constants ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Must match backup-files-and-mysql.sh</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">ZIP_PREFIX</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mybb_files_and_mysql\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Script dir absolute path, unused</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SCRIPT_DIR</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"$(</span><span style=\"color:#79B8FF\">cd</span><span style=\"color:#9ECBFF\"> \"$(</span><span style=\"color:#B392F0\">dirname</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#E1E4E8\">BASH_SOURCE</span><span style=\"color:#9ECBFF\">[0]}\")\" &#x26;&#x26; </span><span style=\"color:#79B8FF\">pwd</span><span style=\"color:#9ECBFF\">)\"</span></span></code></pre>\n<h3 id=\"logging-variables-1\">Logging variables</h3>\n<p>These are the same as in the <a href=\"#logging-variables\">Local backup script</a>. The logging format is identical in both the local and remote scripts.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Logging vars ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Enable only when running from cron</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Cron has no TTY, interactive shell does</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_TO_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">false</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$PS1</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ] &#x26;&#x26; LOG_TO_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">true</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log file</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_FILE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"./log-backup-rsync-local.txt\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log size limits (MB, float allowed)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_MAX_SIZE_MB</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1.0</span><span style=\"color:#6A737D\">   # truncate when log exceeds this</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_KEEP_SIZE_MB</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">0.5</span><span style=\"color:#6A737D\">  # keep last N MB after truncation</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Timezone for log timestamps</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOG_TIMEZONE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Europe/Belgrade\"</span></span></code></pre>\n<h3 id=\"setup-logging-1\">Setup logging</h3>\n<p>Identical as in the local script.</p>\n<h3 id=\"validate-configuration-1\">Validate configuration</h3>\n<p>Same as in the local script, we validate the existence and correctness of the configuration variables before performing any other logic. In this case, we ensure that:</p>\n<ul>\n<li>The SSH connection to the remote host can be established.</li>\n<li>The remote backup directory exists.</li>\n<li>The local backup directory exists.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Validate config ------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">is_valid_config</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"----------------------------------------\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Validating configuration\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check SSH connectivity to remote host</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> ssh</span><span style=\"color:#79B8FF\"> -o</span><span style=\"color:#9ECBFF\"> BatchMode=yes</span><span style=\"color:#79B8FF\"> -o</span><span style=\"color:#9ECBFF\"> ConnectTimeout=</span><span style=\"color:#79B8FF\">5</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"true\"</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\">/dev/null</span><span style=\"color:#F97583\"> 2>&#x26;1</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Cannot connect to remote host via SSH: REMOTE_HOST=</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] SSH connection established: REMOTE_HOST=</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check remote backup directory exists</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> ssh</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"[ -d </span><span style=\"color:#79B8FF\">\\\"</span><span style=\"color:#E1E4E8\">$REMOTE_BACKUP_DIR</span><span style=\"color:#79B8FF\">\\\"</span><span style=\"color:#9ECBFF\"> ]\"</span><span style=\"color:#F97583\"> ></span><span style=\"color:#9ECBFF\">/dev/null</span><span style=\"color:#F97583\"> 2>&#x26;1</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Remote backup directory does not exist: REMOTE_HOST=</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\"> REMOTE_BACKUP_DIR=</span><span style=\"color:#E1E4E8\">$REMOTE_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Remote backup directory exists: </span><span style=\"color:#E1E4E8\">$REMOTE_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Check local backup directory exists</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">!</span><span style=\"color:#F97583\"> -d</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Local backup directory does not exist: path=</span><span style=\"color:#E1E4E8\">$SCRIPT_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Local backup directory exists: </span><span style=\"color:#E1E4E8\">$SCRIPT_DIR</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Configuration validation successful\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"----------------------------------------\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"utility-functions\">Utility functions</h3>\n<p>Besides validating configuration variables, we also need to validate the remote backup on the server before syncing locally. This is a more complex task, so we break it down into a few smaller, reusable utility functions for clarity and readability.</p>\n<ul>\n<li><code>get_latest_date()</code> expects a list of filenames via <code>stdin</code>, extracts the date part in <code>YYYY-MM-DD</code> format, sorts them in descending order, and returns the top item, which is the latest date in the list.</li>\n<li><code>split_backup_types()</code> accepts a list of filenames as a single string (e.g. output from <code>ls</code>) as the first argument. The second argument is a mutable associative array passed by reference, used to store the result. The function parses the raw input string and groups backup filenames into categories (daily, weekly, monthly), storing each group in the corresponding key of the associative array.</li>\n<li><code>check_count()</code> compares remote and local backup counts (for a given type). If the remote count is lower than the local count, it prints an error and returns failure.</li>\n<li><code>check_date()</code> does the same for dates. If the latest remote backup date is older than the latest local backup date, it prints an error and returns failure.</li>\n<li><code>bytes_to_human()</code> converts a size in bytes into a human-readable format (KB, MB, GB). It is used to improve log readability.</li>\n<li><code>check_file_size()</code> validates that all remote backup files meet a minimum size requirement. It fetches file names and sizes from the remote server via SSH and iterates through each file. For each one, it logs the size and checks whether it is smaller than the minimum allowed size <code>MIN_BACKUP_SIZE_BYTES</code>. If any file is too small, it logs the filename and exits early with an error.</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ------------ Utils ------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Extract latest YYYY-MM-DD date from backup filenames</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">get_latest_date</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    sed</span><span style=\"color:#79B8FF\"> -E</span><span style=\"color:#9ECBFF\"> 's/.*-([0-9]{4}-[0-9]{2}-[0-9]{2})\\.zip/\\1/'</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        |</span><span style=\"color:#B392F0\"> sort</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> tail</span><span style=\"color:#79B8FF\"> -n</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Split a list of filenames into daily/weekly/monthly assoc array</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">split_backup_types</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> files</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$1</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    declare</span><span style=\"color:#79B8FF\"> -n</span><span style=\"color:#E1E4E8\"> arr</span><span style=\"color:#F97583\">=</span><span style=\"color:#FFAB70\">$2</span><span style=\"color:#6A737D\">  # pass assoc array by name</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    while</span><span style=\"color:#E1E4E8\"> IFS</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> read</span><span style=\"color:#79B8FF\"> -r</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        case</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> in</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            *</span><span style=\"color:#DBEDFF\">-daily-</span><span style=\"color:#F97583\">*</span><span style=\"color:#DBEDFF\">.zip</span><span style=\"color:#F97583\">)</span><span style=\"color:#E1E4E8\">   arr[daily]</span><span style=\"color:#F97583\">+=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"$'</span><span style=\"color:#79B8FF\">\\n</span><span style=\"color:#9ECBFF\">'</span><span style=\"color:#E1E4E8\"> ;;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            *</span><span style=\"color:#DBEDFF\">-weekly-</span><span style=\"color:#F97583\">*</span><span style=\"color:#DBEDFF\">.zip</span><span style=\"color:#F97583\">)</span><span style=\"color:#E1E4E8\">  arr[weekly]</span><span style=\"color:#F97583\">+=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"$'</span><span style=\"color:#79B8FF\">\\n</span><span style=\"color:#9ECBFF\">'</span><span style=\"color:#E1E4E8\"> ;;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            *</span><span style=\"color:#DBEDFF\">-monthly-</span><span style=\"color:#F97583\">*</span><span style=\"color:#DBEDFF\">.zip</span><span style=\"color:#F97583\">)</span><span style=\"color:#E1E4E8\"> arr[monthly]</span><span style=\"color:#F97583\">+=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"$'</span><span style=\"color:#79B8FF\">\\n</span><span style=\"color:#9ECBFF\">'</span><span style=\"color:#E1E4E8\"> ;;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        esac</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span><span style=\"color:#F97583\"> &#x3C;&#x3C;&#x3C;</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$files</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Ensure remote has at least as many backups as local</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">check_count</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_count</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$1</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> local_count</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$2</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> backup_type</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$3</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (( remote_count </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> local_count )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"ERROR: remote has fewer type=</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\"> backups than local, remote_count=</span><span style=\"color:#E1E4E8\">$remote_count</span><span style=\"color:#9ECBFF\">, local_count=</span><span style=\"color:#E1E4E8\">$local_count</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Ensure remote backups are not older than local</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">check_date</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_latest</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$1</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> local_latest</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$2</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> backup_type</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">$3</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">-n</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_latest</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_latest</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> &#x3C;</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_latest</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"ERROR: remote type=</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\"> backup is older than local, remote_latest=</span><span style=\"color:#E1E4E8\">$remote_latest</span><span style=\"color:#9ECBFF\">, local_latest=</span><span style=\"color:#E1E4E8\">$local_latest</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Convert bytes to human-readable format</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> size</span><span style=\"color:#F97583\">=</span><span style=\"color:#FFAB70\">$1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (( size </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#79B8FF\"> 1024</span><span style=\"color:#E1E4E8\"> )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#E1E4E8\">size</span><span style=\"color:#9ECBFF\">}B\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    elif</span><span style=\"color:#E1E4E8\"> (( size </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#79B8FF\"> 1024</span><span style=\"color:#F97583\">*</span><span style=\"color:#79B8FF\">1024</span><span style=\"color:#E1E4E8\"> )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"$((</span><span style=\"color:#B392F0\">size/1024</span><span style=\"color:#9ECBFF\">))KB\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    elif</span><span style=\"color:#E1E4E8\"> (( size </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#79B8FF\"> 1024</span><span style=\"color:#F97583\">*</span><span style=\"color:#79B8FF\">1024</span><span style=\"color:#F97583\">*</span><span style=\"color:#79B8FF\">1024</span><span style=\"color:#E1E4E8\"> )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"$((</span><span style=\"color:#B392F0\">size/1024/1024</span><span style=\"color:#9ECBFF\">))MB\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    else</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"$((</span><span style=\"color:#B392F0\">size/1024/1024/1024</span><span style=\"color:#9ECBFF\">))GB\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Ensure all remote backups are larger than minimum size</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">check_file_size</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> bad_file bad_file_size</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_file size</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_files_info</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Store SSH output in a variable</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    remote_files_info</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">ssh</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">        for f in </span><span style=\"color:#E1E4E8\">$REMOTE_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/${</span><span style=\"color:#E1E4E8\">ZIP_PREFIX</span><span style=\"color:#9ECBFF\">}-*.zip; do</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            [ -f </span><span style=\"color:#79B8FF\">\\\"\\$</span><span style=\"color:#9ECBFF\">f</span><span style=\"color:#79B8FF\">\\\"</span><span style=\"color:#9ECBFF\"> ] || continue</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            stat -c '%n %s' </span><span style=\"color:#79B8FF\">\\\"\\$</span><span style=\"color:#9ECBFF\">f</span><span style=\"color:#79B8FF\">\\\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">        done</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Iterate over each line in the variable</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    while</span><span style=\"color:#79B8FF\"> read</span><span style=\"color:#79B8FF\"> -r</span><span style=\"color:#9ECBFF\"> remote_file</span><span style=\"color:#9ECBFF\"> size</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Remote file: </span><span style=\"color:#E1E4E8\">$remote_file</span><span style=\"color:#9ECBFF\">, size=$(</span><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\"> $size</span><span style=\"color:#9ECBFF\">)\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> (( size </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> MIN_BACKUP_SIZE_BYTES )); </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            bad_file</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$remote_file</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            bad_file_size</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$size</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            break</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span><span style=\"color:#F97583\"> &#x3C;&#x3C;&#x3C;</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_files_info</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">-n</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$bad_file</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"ERROR: remote backup file too small: </span><span style=\"color:#E1E4E8\">$bad_file</span><span style=\"color:#9ECBFF\">, size=$(</span><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\"> $bad_file_size</span><span style=\"color:#9ECBFF\">), min=$(</span><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\"> $MIN_BACKUP_SIZE_BYTES</span><span style=\"color:#9ECBFF\">)\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] All remote backup files meet minimum size, min=$(</span><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\"> $MIN_BACKUP_SIZE_BYTES</span><span style=\"color:#9ECBFF\">)\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"validate-source-of-truth\">Validate source of truth</h3>\n<p>The <code>is_valid_backup()</code> function is the final check before synchronization. It validates both remote (source of truth) and local backups, compares them for consistency, and ensures that we do not accidentally overwrite the local backup with a corrupted or inconsistent remote backup from the server. It composes the utility functions defined above and adds its own logic:</p>\n<ul>\n<li>It verifies that all remote backup files meet a minimum size requirement.</li>\n<li>For each backup type (daily, weekly, monthly), it ensures that:\n<ul>\n<li>The remote contains more backups than the local.</li>\n<li>The latest backup date on the remote is newer than the latest local backup.</li>\n</ul>\n</li>\n</ul>\n<p>If any validation fails, the function prints an error and exits early with a non-zero status. If all checks pass, it confirms successful validation and returns success.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Validation ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">is_valid_backup</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"----------------------------------------\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Validating backups\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Local variables</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#79B8FF\"> -A</span><span style=\"color:#E1E4E8\"> remote_lists local_lists</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_all_files local_all_files</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Loop variables</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> backup_type</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_list local_list</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_count local_count</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    local</span><span style=\"color:#E1E4E8\"> remote_latest local_latest</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Global size validation (run once)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> check_file_size</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"ERROR: remote backup contains file(s) smaller than minimum size, min=$(</span><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\"> $MIN_BACKUP_SIZE_BYTES</span><span style=\"color:#9ECBFF\">)\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Remote backup file sizes validated, min=$(</span><span style=\"color:#B392F0\">bytes_to_human</span><span style=\"color:#E1E4E8\"> $MIN_BACKUP_SIZE_BYTES</span><span style=\"color:#9ECBFF\">)\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Store remote backup filenames in a variable and split, ignores .gitkeep</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    remote_all_files</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">ssh</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"ls -1 </span><span style=\"color:#E1E4E8\">$REMOTE_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/${</span><span style=\"color:#E1E4E8\">ZIP_PREFIX</span><span style=\"color:#9ECBFF\">}-*.zip 2>/dev/null\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    split_backup_types</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_all_files</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> remote_lists</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">\techo</span><span style=\"color:#9ECBFF\"> \"[INFO] Remote backup file list loaded for type(s):\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">\techo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_all_files</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Store local backup filenames in a variable and split</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    local_all_files</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">ls</span><span style=\"color:#79B8FF\"> -1</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/${</span><span style=\"color:#E1E4E8\">ZIP_PREFIX</span><span style=\"color:#9ECBFF\">}-*.zip\"</span><span style=\"color:#F97583\"> 2></span><span style=\"color:#9ECBFF\">/dev/null</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    split_backup_types</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_all_files</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> local_lists</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">\techo</span><span style=\"color:#9ECBFF\"> \"[INFO] Local backup file list loaded:\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">\techo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_all_files</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    for</span><span style=\"color:#E1E4E8\"> backup_type </span><span style=\"color:#F97583\">in</span><span style=\"color:#9ECBFF\"> daily</span><span style=\"color:#9ECBFF\"> weekly</span><span style=\"color:#9ECBFF\"> monthly</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Checking backup type: </span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Set filename lists</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        remote_list</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">remote_lists</span><span style=\"color:#9ECBFF\">[</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">]}\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        local_list</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">local_lists</span><span style=\"color:#9ECBFF\">[</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">]}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Check counts</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        remote_count</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_list</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#79B8FF\"> -c</span><span style=\"color:#9ECBFF\"> .</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        local_count</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_list</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#79B8FF\"> -c</span><span style=\"color:#9ECBFF\"> .</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> check_count</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_count</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_count</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"ERROR: backup count mismatch for type=</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">: remote=</span><span style=\"color:#E1E4E8\">$remote_count</span><span style=\"color:#9ECBFF\"> is less than local=</span><span style=\"color:#E1E4E8\">$local_count</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Backup count valid: type=</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\"> remote=</span><span style=\"color:#E1E4E8\">$remote_count</span><span style=\"color:#9ECBFF\"> local=</span><span style=\"color:#E1E4E8\">$local_count</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Check latest dates</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        remote_latest</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_list</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> get_latest_date</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        local_latest</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_list</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> get_latest_date</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> check_date</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$remote_latest</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$local_latest</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"ERROR: latest backup date mismatch for type=</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\">: remote=</span><span style=\"color:#E1E4E8\">$remote_latest</span><span style=\"color:#9ECBFF\"> is older than local=</span><span style=\"color:#E1E4E8\">$local_latest</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            return</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        fi</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Latest backup date valid: type=</span><span style=\"color:#E1E4E8\">$backup_type</span><span style=\"color:#9ECBFF\"> date=</span><span style=\"color:#E1E4E8\">$remote_latest</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Backup validation successful\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"----------------------------------------\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#79B8FF\"> 0</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"synchronizing-local-copy\">Synchronizing local copy</h3>\n<p>Finally, we can invoke the validation functions from above and synchronize remote backup to the local machine.</p>\n<p>It first verifies that the configuration is valid, and then that the remote backups are valid. If any validation fails, the script aborts and logs error message.</p>\n<p>If both checks pass, it proceeds to mirror the remote backup directory locally using <code>rsync</code>, preserving structure and deleting any local files that no longer exist on the remote.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ---------- Sync ----------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> is_valid_config</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"[ERROR] Configuration validation failed. Aborting script.\"</span><span style=\"color:#F97583\"> >&#x26;2</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Exit early if remote backup is not valid</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#F97583\"> !</span><span style=\"color:#B392F0\"> is_valid_backup</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"ERROR: Backup validation failed - aborting\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Note: no fallback logic for now</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Remote backup valid - syncing data\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Mirror remote data directory locally</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">rsync</span><span style=\"color:#79B8FF\"> -ah</span><span style=\"color:#79B8FF\"> --progress</span><span style=\"color:#79B8FF\"> --delete</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#E1E4E8\">$REMOTE_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$LOCAL_BACKUP_DIR</span><span style=\"color:#9ECBFF\">/\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"[INFO] Backup sync completed successfully.\"</span></span></code></pre>\n<p>Below is an example log entry from a successful run when synchronizing a backup:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"txt\"><code><span class=\"line\"><span>2026-04-07 00:45:02 ========================================</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 [INFO] Logging started</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 [INFO] Log file: ./log-backup-rsync-local.txt</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 [INFO] Max size: 1.0MB, keep: 0.5MB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 ========================================</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 </span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 ----------------------------------------</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:02 [INFO] Validating configuration</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:03 [INFO] SSH connection established: REMOTE_HOST=arm2</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:04 [INFO] Remote backup directory exists: / ... /traefik-proxy/apps/mybb/backup/data</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:04 [INFO] Local backup directory exists: / ... /mybb-backup/scripts/../data</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:04 [INFO] Configuration validation successful</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:04 ----------------------------------------</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:04 ----------------------------------------</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:04 [INFO] Validating backups</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-daily-2026-04-05.zip, size=279KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-daily-2026-04-06.zip, size=293KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-daily-2026-04-07.zip, size=285KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-monthly-2026-02-01.zip, size=292KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-monthly-2026-03-01.zip, size=334KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-monthly-2026-04-01.zip, size=332KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-weekly-2026-03-22.zip, size=326KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote file: / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-weekly-2026-04-05.zip, size=279KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] All remote backup files meet minimum size, min=102KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:05 [INFO] Remote backup file sizes validated, min=102KB</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Remote backup file list loaded for type(s):</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-daily-2026-04-05.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-daily-2026-04-06.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-daily-2026-04-07.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-monthly-2026-02-01.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-monthly-2026-03-01.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-monthly-2026-04-01.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-weekly-2026-03-22.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 / ... /traefik-proxy/apps/mybb/backup/data/mybb_files_and_mysql-weekly-2026-04-05.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Local backup file list loaded:</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 </span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Checking backup type: daily</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Backup count valid: type=daily remote=3 local=0</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Latest backup date valid: type=daily date=2026-04-07</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Checking backup type: weekly</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Backup count valid: type=weekly remote=2 local=0</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Latest backup date valid: type=weekly date=2026-04-05</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Checking backup type: monthly</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Backup count valid: type=monthly remote=3 local=0</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Latest backup date valid: type=monthly date=2026-04-01</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Backup validation successful</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 ----------------------------------------</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:06 [INFO] Remote backup valid - syncing data</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:07 receiving incremental file list</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:07 deleting mybb_files_and_mysql-daily-2026-04-04.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:07 ./</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:07 mybb_files_and_mysql-daily-2026-04-07.zip</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:07 </span></span>\n<span class=\"line\"><span>              0   0%    0.00kB/s    0:00:00  </span></span>\n<span class=\"line\"><span>        292.31K 100%    1.91MB/s    0:00:00 (xfr#1, to-chk=5/10)</span></span>\n<span class=\"line\"><span>2026-04-07 00:45:07 [INFO] Backup sync completed successfully.</span></span></code></pre>\n<h2 id=\"cron-jobs\">Cron jobs</h2>\n<p>Now that we have implemented the scripts, we just need to schedule them to run daily by defining cron jobs on the server and on each machine that stores synced copies.</p>\n<p>As a reminder, we can list and edit cron jobs using the following commands:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># List crons</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">crontab</span><span style=\"color:#79B8FF\"> -l</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Edit crons</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">crontab</span><span style=\"color:#79B8FF\"> -e</span></span></code></pre>\n<p>We need to carefully choose times that capture application data near the end of the day in our target time zone. The sync script must run after the backup creation script, so we need to estimate the execution time of the backup process and schedule the sync within a safe margin. With this in mind, we can schedule the backup at <code>23:30</code> and the sync at <code>23:45</code>.</p>\n<p>Another important detail is that both scripts rely on relative paths, so cron must execute them from the correct working directory. This can be achieved with <code>cd /.../backup/scripts &#x26;&#x26; bash ./my-script.sh</code>. Additionally, we should use absolute paths in the cron configuration (avoiding shortcuts like <code>~</code> for the home directory), as such expansions may fail in a cron environment.</p>\n<p>On server:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Create backup every day at 23:30 Belgrade (UTC+2) time (21:30 UTC)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">30</span><span style=\"color:#79B8FF\"> 21</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\"> cd</span><span style=\"color:#9ECBFF\"> /home/username/traefik-proxy/apps/mybb/backup/scripts</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">/usr/bin/bash</span><span style=\"color:#9ECBFF\"> ./run-backup-files-and-mysql.sh</span></span></code></pre>\n<p>On syncing machines:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Sync backup every day at 23:45 Belgrade (UTC+2) time (21:45 UTC)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">45</span><span style=\"color:#79B8FF\"> 21</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\"> cd</span><span style=\"color:#9ECBFF\"> /home/username/mybb-backup/scripts</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">/usr/bin/bash</span><span style=\"color:#9ECBFF\"> ./run-backup-rsync-local.sh</span></span></code></pre>\n<p>Interestingly, I couldn’t find a reliable way to set a custom time zone for cron jobs. I tried setting the <code>TZ</code> and <code>CRON_TZ</code> variables, but they were ignored, and cron always fell back to UTC.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># None of these actually sets the time zone successfully</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Always falls back to UTC</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Global</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">TZ</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">Europe/Belgrade</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">CRON_TZ</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">Europe/Belgrade</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Per job</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">30</span><span style=\"color:#79B8FF\"> 21</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\"> TZ=Europe/Belgrade</span><span style=\"color:#9ECBFF\"> cd</span><span style=\"color:#9ECBFF\"> /home/username/traefik-proxy/apps/mybb/backup/scripts</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">/usr/bin/bash</span><span style=\"color:#9ECBFF\"> ./run-backup-files-and-mysql.sh</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">30</span><span style=\"color:#79B8FF\"> 21</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\"> CRON_TZ=Europe/Belgrade</span><span style=\"color:#9ECBFF\"> cd</span><span style=\"color:#9ECBFF\"> /home/username/traefik-proxy/apps/mybb/backup/scripts</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">/usr/bin/bash</span><span style=\"color:#9ECBFF\"> ./run-backup-files-and-mysql.sh</span></span></code></pre>\n<h2 id=\"room-for-improvements\">Room for improvements</h2>\n<p>We described an example implementation along with the thought process behind designing a usable backup script. It is certainly not perfect or final, and it can be enhanced and improved in a number of ways. Here are some possible improvements:</p>\n<ul>\n<li>Extract all configuration variables from the script and load them from an <code>.env</code> file.</li>\n<li>Add an <code>ENABLE_ASSETS</code> boolean flag to enable or disable including application file assets in the backup. Currently, this requires commenting out all keys in the <code>SRC_CODE_DIRS</code> associative array.</li>\n<li>Create a decentralized solution by avoiding a single “source of truth” backup on the server. Instead, allow local backup repositories to connect to the server via SSH and execute code that creates a temporary backup, which can then be downloaded locally and deleted afterward. In practice, <code>backup-rsync-local.sh</code> would send and execute the <code>backup-files-and-mysql.sh</code> script remotely and clean up the temporary backup after downloading.</li>\n<li>Set up a test environment with sample data to conveniently test and validate the scripts without waiting for scheduled time intervals (daily, weekly, monthly copies).</li>\n<li>Find a reliable and actually working solution for configuring the time zone for cron jobs on Ubuntu.</li>\n</ul>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Backup script:</strong> <a href=\"https://github.com/nemanjam/bash-backup\">https://github.com/nemanjam/bash-backup</a></li>\n<li><strong>Example application:</strong> <a href=\"https://github.com/nemanjam/mybb-docker\">https://github.com/nemanjam/mybb-docker</a></li>\n</ul>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>The assumption was that we need to track the state of an application, including the database, configuration (source files), and assets (images), and that we need a simple, minimalistic, yet functional solution. Although this is a quite common use case, after searching I couldn’t find a convincing, up-to-date Bash script for this purpose. So, I decided to build upon and adapt the closest existing script I could find. That process is described in this article.</p>\n<p>This is a pragmatic, custom script focused on simplicity, with no ambition to become a comprehensive backup solution covering many use cases and features. Such a solution would require a much larger scope of work, and many robust backup tools already exist.</p>\n<p>How do you approach creating and managing backups for your applications? Let me know in the comments.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>The original script <a href=\"https://github.com/todiadiyatmo/bash-backup-rotation-script\">https://github.com/todiadiyatmo/bash-backup-rotation-script</a></li>\n<li>Crontab set custom time zone <a href=\"https://serverfault.com/questions/848829/how-to-use-timezone-with-cron-tab\">https://serverfault.com/questions/848829/how-to-use-timezone-with-cron-tab</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2026-04-07-bash-backup-script/",
            "title": "Building a simple Bash backup script with Docker, MySQL and rsync",
            "summary": "Step-by-step guide to designing pragmatic bash scripts for automated backups,\nincluding database dumps, file archiving, retention policies, validation,\nand remote synchronization.\n",
            "date_modified": "2026-04-07T00:00:00.000Z",
            "date_published": "2026-04-07T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2026-03-13-rsync-scp/",
            "content_html": "<h2 id=\"introduction\">Introduction</h2>\n<p>Many of you can probably guess the point of this article just by reading the title, but it’s still useful to have a clear reminder backed by some real-world measurements. This will be a practical, straight-to-the-point article.</p>\n<h2 id=\"the-problem-with-scp-in-deployments\">The problem with scp in deployments</h2>\n<p>When copying the <code>dist</code> folder to a deployment server, the first instinct is usually to clear the existing folder and upload the new one using <code>scp</code>. While this works, you can achieve significant long-term improvements by replacing a few lines and using <code>rsync</code> instead.</p>\n<p>The important facts to keep in mind are:</p>\n<ol>\n<li>This is a repeated operation.</li>\n<li>The server will (almost) always already contain a previous copy of the <code>dist</code> folder.</li>\n<li>Not all files in the build artifacts change on every build, many remain exactly the same and can be reused.</li>\n</ol>\n<p>Clearing the <code>dist</code> folder and <code>scp</code> the entire content each time simply ignores the facts above. <code>scp</code> it is not optimized for repeated copying where most files remain unchanged.</p>\n<p>Bash deployment scripts and Github Actions deployment workflows run frequently, so any unnecessary time or performance overhead accumulates and wastes energy and resources. It’s important to optimize as much as possible, especially when it requires very little effort.</p>\n<h2 id=\"why-rsync-is-faster\">Why rsync is faster</h2>\n<p><code>rsync</code> is designed specifically for efficient file synchronization. Instead of copying everything every time, it compares the source and destination and transfers only the files that have changed.</p>\n<p>This dramatically reduces the amount of data that needs to be sent during deployments. In most cases, only a small subset of files changes between builds, which means <code>rsync</code> can complete the transfer much faster than <code>scp</code>.</p>\n<p>Another advantage is that <code>rsync</code> can resume partially transferred files and optionally compress data during transfer. These features make it especially well suited for automated deployment workflows where speed and reliability are important.</p>\n<h2 id=\"rsync-flags-for-deployments\">rsync flags for deployments</h2>\n<p>A typical <code>rsync</code> command used in deployments looks like this:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">rsync</span><span style=\"color:#79B8FF\"> -az</span><span style=\"color:#79B8FF\"> --delete</span><span style=\"color:#9ECBFF\"> ./dist/</span><span style=\"color:#9ECBFF\"> user@server:/var/www/site</span></span></code></pre>\n<p>Some commonly used flags include:</p>\n<ul>\n<li><code>-a</code> (archive) preserves permissions, timestamps, and recursively copies files.</li>\n<li><code>-z</code> enables compression during transfer, which reduces network usage.</li>\n<li><code>--delete</code> removes files on the destination that no longer exist in the source, keeping the deployment directory in sync.</li>\n<li><code>--partial</code> allows interrupted transfers to resume instead of restarting from scratch.</li>\n</ul>\n<p>Together, these options make rsync a powerful and efficient tool for copying build artifacts during automated deployments.</p>\n<p>A complete list of options is available in the command’s manual: <a href=\"https://download.samba.org/pub/rsync/rsync.1#OPTION_SUMMARY\">https://download.samba.org/pub/rsync/rsync.1#OPTION_SUMMARY</a>.</p>\n<h2 id=\"example-deployment-with-scp\">Example: deployment with scp</h2>\n<p>For both <code>scp</code> and <code>rsync</code>, we will consider two examples: a Bash script used to deploy from a local development environment, and a Github Actions workflow. These represent two common approaches to deployments.</p>\n<p>For the sake of context and completeness, the full scripts are included so you can reuse them or run your own tests and performance comparisons.</p>\n<h3 id=\"bash-script\">Bash script</h3>\n<p>Naturally, the only truly important part is the <code>scp</code> line. However, let’s briefly review the rest of the script, since it demonstrates what we would typically use in a real-world scenario.</p>\n<p>The first assumption is that we have a local <code>dist</code> folder containing the compiled application built with a local <code>.env</code> file, and an Nginx web server with a webroot directory on a remote server. Our Bash script accepts three input arguments: <code>LOCAL_PATH</code>, <code>REMOTE_PATH</code>, and <code>REMOTE_HOST</code>, which we validate before performing the copy.</p>\n<p>Next, we establish an initial <code>ssh</code> connection to delete the existing application artifacts from the previous deployment. During this step, we also log some information by printing the file list and the total number of files in the Nginx webroot before and after removing the old files.</p>\n<p><strong>Note 1:</strong> When removing old artifacts, we delete the <strong>contents</strong> of the Nginx webroot directory, <strong>not</strong> the webroot directory itself. Removing the directory could disrupt the current Nginx session and would require restarting the Nginx process or container.</p>\n<p><strong>Note 2:</strong> Below the <code>scp</code> line, I also include a <code>tar ... | ssh</code> command example that compresses the artifacts before piping them through the SSH connection. In theory, this should provide performance similar to <code>rsync</code> in scenarios where we always completely clear the previous deployment. I will include it in the measurements so we can see how it performs.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Navigate to ~/traefik-proxy/apps/nmc-nginx-with-volume/website</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">cd</span><span style=\"color:#E1E4E8\"> $REMOTE_PATH</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Clear the contents, not the `/website` path segment</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">rm</span><span style=\"color:#79B8FF\"> -rf</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#E1E4E8\"> </span></span></code></pre>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/scripts/deploy-nginx.sh\">https://github.com/nemanjam/nemanjam.github.io/blob/main/scripts/deploy-nginx.sh</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\">#!/bin/bash</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOCAL_PATH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"./dist\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># REMOTE_PATH=\"~/traefik-proxy/apps/nmc-nginx-with-volume/website\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># REMOTE_HOST=\"arm1\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REMOTE_PATH</span><span style=\"color:#F97583\">=</span><span style=\"color:#FFAB70\">$1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REMOTE_HOST</span><span style=\"color:#F97583\">=</span><span style=\"color:#FFAB70\">$2</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Check if all arguments are provided</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#F97583\"> -z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  echo</span><span style=\"color:#9ECBFF\"> \"Incorrect args, usage: </span><span style=\"color:#79B8FF\">$0</span><span style=\"color:#9ECBFF\"> &#x3C;remote_path> &#x3C;remote_host>\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Navigate to the website folder on the remote server and clear contents of the website folder</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#E1E4E8\"> $REMOTE_HOST </span><span style=\"color:#9ECBFF\">\"cd </span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span><span style=\"color:#9ECBFF\"> &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Listing files before clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'List before clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  ls &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Count before clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  ls -l | grep -v ^l | wc -l &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # Only possible to skip with rsync --delete</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Clearing contents of the folder...' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  rm -rf * &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'List after clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  ls &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Count after clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  find . -type f | wc -l &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Copying new contents...'\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Copy new contents, 320 MB</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Using scp -rq, slowest, not resumable</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">scp</span><span style=\"color:#79B8FF\"> -rq</span><span style=\"color:#E1E4E8\"> $LOCAL_PATH</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#79B8FF\">*</span><span style=\"color:#E1E4E8\"> $REMOTE_HOST</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Using tar, fast for cleaned dir</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># tar cf - -C \"$LOCAL_PATH\" . | ssh \"$REMOTE_HOST\" \"tar xvf - -C $REMOTE_PATH\" >/dev/null 2>&#x26;1</span></span></code></pre>\n<p>Then we can call the Bash script like this by passing <code>REMOTE_PATH</code> and <code>REMOTE_HOST</code> arguments:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"scripts\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"deploy:nginx:rpi\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"bash scripts/deploy-nginx.sh '~/traefik-proxy/apps/nmc-nginx-with-volume/website' rpi\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"github-actions\">Github Actions</h3>\n<p>The Github Actions workflow provides even more context. It includes environment variables required by the application, sets up Node.js and pnpm, and builds the app. The rest is identical to the Bash script above: we use the <a href=\"https://github.com/appleboy/ssh-action\">appleboy/ssh-action</a> action to establish an SSH connection and clear the previous deployment, and the <a href=\"https://github.com/appleboy/scp-action\">appleboy/scp-action</a> action to copy the built <code>dist/</code> folder to the remote server using <code>scp</code>.</p>\n<p>In the <code>scp</code> step, most arguments are self-explanatory, but one worth emphasizing is <code>strip_components: 1</code>. This prevents creating an additional <code>dist/</code> path segment inside the Nginx webroot. In other words, we want the files copied to <code>nmc-nginx-with-volume/website/*</code>, not to <code>nmc-nginx-with-volume/website/dist/*</code>.</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/.github/workflows/default__deploy-nginx-scp.yml\">https://github.com/nemanjam/nemanjam.github.io/blob/main/.github/workflows/default__deploy-nginx-scp.yml</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Deploy Nginx scp</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">on</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  push</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'main'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    tags</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'v[0-9]+.[0-9]+.[0-9]+'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  pull_request</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'disabled-main'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  workflow_dispatch</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">env</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  SITE_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://nemanjamitic.com'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  PLAUSIBLE_SCRIPT_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://plausible.arm1.nemanjamitic.com/js/script.js'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  PLAUSIBLE_DOMAIN</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'nemanjamitic.com'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">jobs</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  deploy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    runs-on</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ubuntu-latest</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    steps</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Checkout code</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">actions/checkout@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          fetch-depth</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Print commit id, message and tag</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#F97583\">|</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">          git show -s --format='%h %s'</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">          echo \"github.ref -> {{ github.ref }}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Set up Node.js and pnpm</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">actions/setup-node@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          node-version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">24.13.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          registry-url</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://registry.npmjs.org'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Install pnpm</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm/action-setup@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">10.30.1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Install dependencies</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm install --frozen-lockfile</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Build nemanjamiticcom</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm build</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Clean up website dir</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">appleboy/ssh-action@master</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          host</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_HOST }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          username</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_USERNAME }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          key</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_KEY_ED25519 }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          port</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_PORT }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          script_stop</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          script</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#F97583\">|</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            cd /home/ubuntu/traefik-proxy/apps/nmc-nginx-with-volume/website</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            echo \"Content before deletion: $(pwd)\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            ls -la</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            rm -rf ./*</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            echo \"Content after deletion: $(pwd)\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            ls -la</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Copy dist folder to remote host</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">appleboy/scp-action@v0.1.7</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          host</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_HOST }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          username</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_USERNAME }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          key</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_KEY_ED25519 }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          port</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_PORT }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          source</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'dist/'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          target</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'/home/ubuntu/traefik-proxy/apps/nmc-nginx-with-volume/website'</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">          # remove /dist path segment</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          strip_components</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span></span></code></pre>\n<h2 id=\"example-deployment-with-rsync\">Example: deployment with rsync</h2>\n<p>Now we modify the existing Bash script and Github Actions workflow by replacing <code>scp</code> with <code>rsync</code>, while keeping the rest of the code identical.</p>\n<h3 id=\"bash-script-1\">Bash script</h3>\n<p>Most of the script remains the same. However, since we use <code>rsync --delete</code>, we can omit the step that deletes the previous deployment. In fact, the initial SSH call is no longer necessary, but we will keep it for debugging and transparency.</p>\n<p>Another option worth mentioning is <code>--info=progress2</code>, which is very convenient in a live terminal session because it displays the current transfer progress in a concise way. This provides reassurance that the network connection is active and the transfer is progressing.</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/scripts/deploy-nginx.sh\">https://github.com/nemanjam/nemanjam.github.io/blob/main/scripts/deploy-nginx.sh</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\">#!/bin/bash</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">LOCAL_PATH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"./dist\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># REMOTE_PATH=\"~/traefik-proxy/apps/nmc-nginx-with-volume/website\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># REMOTE_HOST=\"arm1\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REMOTE_PATH</span><span style=\"color:#F97583\">=</span><span style=\"color:#FFAB70\">$1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REMOTE_HOST</span><span style=\"color:#F97583\">=</span><span style=\"color:#FFAB70\">$2</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Check if all arguments are provided</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> [[ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#F97583\"> -z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$REMOTE_HOST</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  echo</span><span style=\"color:#9ECBFF\"> \"Incorrect args, usage: </span><span style=\"color:#79B8FF\">$0</span><span style=\"color:#9ECBFF\"> &#x3C;remote_path> &#x3C;remote_host>\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Navigate to the website folder on the remote server and clear contents of the website folder</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#E1E4E8\"> $REMOTE_HOST </span><span style=\"color:#9ECBFF\">\"cd </span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span><span style=\"color:#9ECBFF\"> &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Listing files before clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'List before clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  ls &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Count before clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  ls -l | grep -v ^l | wc -l &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # Only possible to skip with rsync --delete</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # echo 'Clearing contents of the folder...' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # rm -rf * &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # echo 'List after clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # ls &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # echo 'Count after clearing:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  # find . -type f | wc -l &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Copying new contents...'\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Using rsync, fastest, resumable, deletes without clearing, lot faster with reusing unchanged files (--delete)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">rsync</span><span style=\"color:#79B8FF\"> -az</span><span style=\"color:#79B8FF\"> --delete</span><span style=\"color:#79B8FF\"> --info=stats2,progress2</span><span style=\"color:#E1E4E8\"> $LOCAL_PATH</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\"> $REMOTE_HOST</span><span style=\"color:#9ECBFF\">:</span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># List all files after copying</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#E1E4E8\"> $REMOTE_HOST </span><span style=\"color:#9ECBFF\">\"cd </span><span style=\"color:#E1E4E8\">$REMOTE_PATH</span><span style=\"color:#9ECBFF\"> &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'List after copying:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  ls &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  echo 'Count after copying:' &#x26;&#x26; </span><span style=\"color:#79B8FF\">\\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                  find . -type f | wc -l\"</span></span></code></pre>\n<h3 id=\"github-actions-1\">Github Actions</h3>\n<p>The workflow implements the same logic using the <a href=\"https://github.com/Burnett01/rsync-deployments\">Burnett01/rsync-deployments</a> action. Since this is not a live terminal session, <code>--info=stats2</code> is sufficient for logging.</p>\n<p>Unless you are actively debugging, avoid using the <code>rsync -v</code> flag, as overly verbose logs reduce readability.</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/.github/workflows/default__deploy-nginx-rsync.yml\">https://github.com/nemanjam/nemanjam.github.io/blob/main/.github/workflows/default__deploy-nginx-rsync.yml</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Deploy Nginx rsync</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">on</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  push</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'main'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    tags</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'v[0-9]+.[0-9]+.[0-9]+'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  pull_request</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'disabled-main'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  workflow_dispatch</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">env</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  SITE_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://nemanjamitic.com'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  PLAUSIBLE_SCRIPT_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://plausible.arm1.nemanjamitic.com/js/script.js'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  PLAUSIBLE_DOMAIN</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'nemanjamitic.com'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">jobs</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  deploy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    runs-on</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ubuntu-latest</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    steps</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Checkout code</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">actions/checkout@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          fetch-depth</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Print commit id, message and tag</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#F97583\">|</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">          git show -s --format='%h %s'</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">          echo \"github.ref -> ${{ github.ref }}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Set up Node.js and pnpm</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">actions/setup-node@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          node-version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">24.13.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          registry-url</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://registry.npmjs.org'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Install pnpm</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm/action-setup@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">10.30.1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Install dependencies</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm install --frozen-lockfile</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Build nemanjamiticcom</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm build</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Deploy dist via rsync</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">burnett01/rsync-deployments@v8</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          switches</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">-az --delete --info=stats2</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          path</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">dist/</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          remote_path</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">/home/ubuntu/traefik-proxy/apps/nmc-nginx-with-volume/website/</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          remote_host</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_HOST }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          remote_user</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_USERNAME }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          remote_port</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_PORT }}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          remote_key</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.REMOTE_KEY_ED25519 }}</span></span></code></pre>\n<h2 id=\"performance-comparison\">Performance comparison</h2>\n<p>For Bash script measurements, I used my local network to deploy to a Raspberry Pi server. I used 1 Gbps Ethernet and 5 GHz, 433 Mbps WiFi 5. For Github Actions workflows, I used the standard Github runners available on the free plan. For each case, I took a few measurements to eliminate random anomalies. I didn’t aim for statistical accuracy.</p>\n<p>For deployment, I used this very static Astro website you are currently reading. Its build artifacts consist of 1320 files totaling 347 MB (it contains a number of images).</p>\n<div class=\"expand-sm overflow-x-auto\">\n  <div class=\"min-w-max\">\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<table><thead><tr><th>Method</th><th>Network</th><th>Transfer strategy</th><th align=\"right\">Files sent</th><th align=\"right\">Data transferred</th><th align=\"right\">Time (s)</th></tr></thead><tbody><tr><td>Bash + scp</td><td>LAN (Ethernet)</td><td>Copy all files every deployment</td><td align=\"right\">1320</td><td align=\"right\">347 MB</td><td align=\"right\">9.6</td></tr><tr><td>Bash + tar over SSH</td><td>LAN (Ethernet)</td><td>Archive then copy single file</td><td align=\"right\">1</td><td align=\"right\">347 MB</td><td align=\"right\">4.1</td></tr><tr><td>Bash + rsync (cleared)</td><td>LAN (Ethernet)</td><td>Full file synchronization</td><td align=\"right\">1320</td><td align=\"right\">347 MB</td><td align=\"right\">4.6</td></tr><tr><td>Bash + rsync</td><td>LAN (Ethernet)</td><td>Incremental file synchronization</td><td align=\"right\">x</td><td align=\"right\">24 MB</td><td align=\"right\">8.8</td></tr><tr><td>Bash + scp</td><td>LAN (WiFi 5)</td><td>Copy all files every deployment</td><td align=\"right\">1320</td><td align=\"right\">347 MB</td><td align=\"right\">188</td></tr><tr><td>Bash + tar over SSH</td><td>LAN (WiFi 5)</td><td>Archive then copy single file</td><td align=\"right\">1</td><td align=\"right\">347 MB</td><td align=\"right\">16.6</td></tr><tr><td>Bash + rsync (cleared)</td><td>LAN (WiFi 5)</td><td>Full file synchronization</td><td align=\"right\">1320</td><td align=\"right\">347 MB</td><td align=\"right\">15.1</td></tr><tr><td>Bash + rsync</td><td>LAN (WiFi 5)</td><td>Incremental file synchronization</td><td align=\"right\">x</td><td align=\"right\">24 MB</td><td align=\"right\">14.6</td></tr><tr><td>GA + scp</td><td>Internet</td><td>Copy all files every deployment</td><td align=\"right\">1320</td><td align=\"right\">347 MB</td><td align=\"right\">43</td></tr><tr><td>GA + tar over SSH</td><td>Internet</td><td>Archive then copy single file</td><td align=\"right\">1</td><td align=\"right\">347 MB</td><td align=\"right\">32</td></tr><tr><td>GA + rsync (cleared)</td><td>Internet</td><td>Full file synchronization</td><td align=\"right\">1320</td><td align=\"right\">347 MB</td><td align=\"right\">29</td></tr><tr><td>GA + rsync</td><td>Internet</td><td>Incremental file synchronization</td><td align=\"right\">x</td><td align=\"right\">24 MB</td><td align=\"right\">10</td></tr></tbody></table>\n  </div>\n</div>\n<h3 id=\"results-discussion\">Results discussion</h3>\n<p>Let’s comment on the results, starting from the worst option:</p>\n<ul>\n<li><code>scp</code> has the worst performance in every case (Ethernet (9.6 s), WiFi (188 s), Github Actions (43 s)). The WiFi result is especially bad (188 seconds). I don’t have an exact explanation, but the WiFi connection probably doesn’t handle a large number of files well.</li>\n<li><code>tar + SSH</code> has decent performance (Ethernet (4.1 s), WiFi (16.6 s), Github Actions (32 s)), considering that it clears the destination and transfers all files every time. Interestingly, on Ethernet it even performs 2× better (4.1 s) than <code>rsync</code> (with synchronization enabled) (8.8 s). I explain this by the fact that hashing and comparing files in <code>rsync</code> can cost more than the file transfer itself on a stable, wired Ethernet connection.</li>\n<li><code>rsync (cleared)</code> (delete the destination and transfer everything each time) is on par with <code>tar + SSH</code> (Ethernet (4.6 s), WiFi (15.1 s), Github Actions (29 s)). This makes sense because those two methods are basically doing the same thing.</li>\n<li><code>rsync</code> (synchronization enabled) overall has the best performance (Ethernet (8.8 s), WiFi (14.6 s), Github Actions (10 s)), with the exception of Ethernet, which I already explained (hashing and file comparison can cost more than network transfer). The Github Actions result (10 s) is especially important, since CI is the most common way to deploy apps in practice. It also creates around 14× less network traffic (24 MB compared to 347 MB).</li>\n</ul>\n<p>Meaning, they rank in the following order (from best to worst):</p>\n<ol>\n<li><code>rsync</code></li>\n<li><code>rsync (cleared)</code> and <code>tar + SSH</code> (equally fast)</li>\n<li><code>scp</code> (worst in every scenario)</li>\n</ol>\n<p><strong>Key takeaway:</strong> In Github Actions, <code>rsync</code> saves <code>43 - 10 = 33 seconds</code> on each run compared to <code>scp</code>, which is a significant improvement.</p>\n<h2 id=\"deployment-process-and-amdahls-law\">Deployment process and Amdahl’s law</h2>\n<p>Transferring files is just one of the steps within the deployment process. It is not even the most dominant one. If we look at the times for each step in the Github Actions <code>default__deploy-nginx-rsync.yml</code> workflow, we can see the following:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"txt\"><code><span class=\"line\"><span>Set up job                                    2s</span></span>\n<span class=\"line\"><span>Build burnett01/rsync-deployments@v8          9s</span></span>\n<span class=\"line\"><span>Checkout code                                18s</span></span>\n<span class=\"line\"><span>Print commit id, message and tag              0s</span></span>\n<span class=\"line\"><span>Set up Node.js and pnpm                       5s</span></span>\n<span class=\"line\"><span>Install pnpm                                  1s</span></span>\n<span class=\"line\"><span>Install dependencies                          6s</span></span>\n<span class=\"line\"><span>Build nemanjamiticcom                     2m 25s</span></span>\n<span class=\"line\"><span>Deploy dist via rsync                        10s</span></span>\n<span class=\"line\"><span>Post Install pnpm                             0s</span></span>\n<span class=\"line\"><span>Post Set up Node.js and pnpm                  0s</span></span>\n<span class=\"line\"><span>Post Checkout code                            0s</span></span>\n<span class=\"line\"><span>Complete job                                  0s</span></span></code></pre>\n<p><code>Deploy dist via rsync</code> is third on the list with 10 seconds. <code>Checkout code</code> is second with 18 seconds. That step already has the <code>fetch-depth: 1</code> optimization; the repository simply has a large file size. The app build step <code>Build nemanjamiticcom</code> obviously takes the most time and has the greatest potential for optimizing performance and saving time. Although obvious, this fact is also formally articulated by <a href=\"https://en.wikipedia.org/wiki/Amdahl%27s_law\">Amdahl’s law</a>, which states:</p>\n<blockquote>\n<p>The overall performance improvement gained by optimizing a single part of a system is limited by the fraction of time that the improved part is actually used.</p>\n</blockquote>\n<p>However, the app’s build step is also the most complex to optimize. It spans the app code implementation, build configuration, and caching on both Vite and Github Actions levels. Naturally, it is largely app-dependent and more challenging to generalize.</p>\n<p>If I look at the Astro build log, I can see this:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"txt\"><code><span class=\"line\"><span>/_astro/snow1.DTiId6LS_Z2cIRXL.webp (reused cache entry) (+2ms) (1044/1101)</span></span></code></pre>\n<p>This image, <code>snow1.DTiId6LS_Z2cIRXL.webp</code>, has the exact same name in each build and is cached and reused, which drastically improves performance.</p>\n<p>On the other hand, in the build log I can also see:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"txt\"><code><span class=\"line\"><span>λ src/pages/api/open-graph/[...route].png.ts</span></span>\n<span class=\"line\"><span>  ├─ /api/open-graph/blog/2026-01-03-nextjs-server-actions-fastapi-openapi.png (+1.42s) </span></span></code></pre>\n<p>This is an Open Graph image generated using a Satori HTML template, and it is regenerated from scratch on each build. I can see two problems with this:</p>\n<ol>\n<li>Generating gradient colors in <code>src/utils/gradients.ts</code> uses <code>Math.random()</code>, which makes the generation non-deterministic. Instead, the gradient should use a pseudo-random, deterministic approach, for example by hashing the page title string.</li>\n<li>The image <code>snow1.DTiId6LS_Z2cIRXL.webp</code> is originally placed inside the <code>src</code> directory, which registers it as an Astro asset. As a result, Astro handles compression, naming, and caching during the build process. This is not the case with the <code>src/pages/api/open-graph/[...route].png.ts</code> static route and the Satori template; additional configuration would be required to enable caching.</li>\n</ol>\n<p>Anyway, that is a separate topic for a completely different article. In this one, we focus on the file transfer step, which can be optimized with <strong>minimal effort</strong> - simply by replacing <strong>a single command</strong>.</p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Repository:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io\">https://github.com/nemanjam/nemanjam.github.io</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> clone</span><span style=\"color:#9ECBFF\"> git@github.com:nemanjam/nemanjam.github.io.git</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Bash</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">scripts/deploy-nginx.sh</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Github Actions</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.github/workflows/default__deploy-nginx-scp.yml</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.github/workflows/default__deploy-nginx-rsync.yml</span></span></code></pre>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>You might think, “This is a pretty long and verbose article for something that could be explained in two sentences” and you would probably be right. However, besides the main <code>rsync</code> vs <code>scp</code> point, I wanted to provide a drop-in script and workflow that you can reuse with minimal changes, just adjust the environment variables, build command, and deployment paths.</p>\n<p>Additionally, real-world measurements help provide a realistic sense of how significant the performance improvements can be.</p>\n<p>What methods do you use to optimize the deployment process in your projects? Let me know in the comments.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Rsync repository <a href=\"https://github.com/RsyncProject/rsync\">https://github.com/RsyncProject/rsync</a></li>\n<li>Rsync manual, options <a href=\"https://download.samba.org/pub/rsync/rsync.1#OPTION_SUMMARY\">https://download.samba.org/pub/rsync/rsync.1#OPTION_SUMMARY</a></li>\n<li>Rsync Github Action <a href=\"https://github.com/Burnett01/rsync-deployments\">https://github.com/Burnett01/rsync-deployments</a></li>\n<li>SSH Github Action <a href=\"https://github.com/appleboy/ssh-action\">https://github.com/appleboy/ssh-action</a></li>\n<li>SCP Github Action <a href=\"https://github.com/appleboy/scp-action\">https://github.com/appleboy/scp-action</a></li>\n<li>Amdahl’s law <a href=\"https://en.wikipedia.org/wiki/Amdahl%27s_law\">https://en.wikipedia.org/wiki/Amdahl%27s_law</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2026-03-13-rsync-scp/",
            "title": "Why you should use rsync instead of scp in deployments",
            "summary": "A practical performance comparison of deployment file transfer methods and why rsync usually outperforms scp.\n",
            "date_modified": "2026-03-13T00:00:00.000Z",
            "date_published": "2026-03-13T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2026-02-26-vercel-static-github-actions/",
            "content_html": "<h2 id=\"introduction\">Introduction</h2>\n<p>This article focuses specifically on deploying static websites to Vercel. In a previous article <a href=\"https://nemanjamitic.com/blog/2026-02-22-vercel-deploy-fastapi-nextjs\">https://nemanjamitic.com/blog/2026-02-22-vercel-deploy-fastapi-nextjs</a>, we covered in detail how to deploy a full-stack application using the Vercel CLI from a local development environment. This time, we will use the same CLI inside a Github Actions runner to automate redeploying a static website on every push, for example, after adding a new blog article in markdown.</p>\n<p>As an example, we will deploy the same blog website you are currently reading. The site itself is a statically built Astro application.</p>\n<h2 id=\"vercel-github-integration-vs-github-actions\">Vercel Github integration vs Github Actions</h2>\n<p>Vercel supports deployments through a Github integration (documented here: <a href=\"https://vercel.com/docs/git/vercel-for-github\">https://vercel.com/docs/git/vercel-for-github</a>). You provide Vercel with your Github repository URL and read access, and Vercel automatically redeploys your application on every push. If you prefer not to grant Vercel access to your source code or Github repository, or if you want more control over the deployment process, you can instead use Github Actions, the approach described in this article.</p>\n<h2 id=\"vercel-configuration-files\">Vercel configuration files</h2>\n<p>As with any Vercel deployment, you need to provide Vercel with additional information about the project’s build process, such as the framework, build command, and output directory, as well as which files should be included or ignored during deployment.</p>\n<p>Before adding any configuration files, go to your Vercel dashboard, create a new project, give it a name, and set all required environment variables.</p>\n<h3 id=\"verceljson\">vercel.json</h3>\n<p>The contents of the <code>vercel.json</code> file are mostly self-explanatory. We specify the <code>astro</code> framework, and the build command and output directory match those used in the local development environment. With this configuration, Vercel knows exactly how to build the application.</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/vercel.json\">https://github.com/nemanjam/nemanjam.github.io/blob/main/vercel.json</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"framework\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"astro\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"buildCommand\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"pnpm build\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"outputDirectory\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"dist\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"cleanUrls\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"trailingSlash\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">false</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"vercelignore\">.vercelignore</h3>\n<p>For performance reasons, it is important to avoid uploading files that are not used during the build and deployment process, such as dependencies, <code>.env*</code> files, documentation, or Docker-related configuration. The <code>.vercelignore</code> file is used to exclude these unnecessary files. Additionally, on the free tier, your deployment must stay below the 250 MB size limit.</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/.vercelignore\">https://github.com/nemanjam/nemanjam.github.io/blob/main/.vercelignore</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Node / package managers</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">node_modules</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.pnpm-store</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.npm</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.yarn</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ! Needed for commit info</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Vercel omits it by default, now way to upload it</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># .git</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># .gitignore</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Local env files</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.env</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.env.*</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">!</span><span style=\"color:#B392F0\">.env.*example</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Logs</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">npm-debug.log*</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn-debug.log*</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn-error.log*</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Docker &#x26; tooling</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">docker/</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">scripts/</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Documentation &#x26; notes</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">docs/</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># OS / editor junk</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.DS_Store</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.idea</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.vscode</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Astro build cache</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.astro/*</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Keep types if build needs them</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">!</span><span style=\"color:#B392F0\">.astro/types.d.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Github</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.github/</span></span></code></pre>\n<p>The exact contents of this file depend on your specific project. To ensure you have excluded all unnecessary paths, go to your Vercel dashboard and navigate to <strong>My Project -> My Deployment -> Source</strong>, where you can clearly see exactly which files are uploaded.</p>\n<h2 id=\"github-actions-workflow\">Github Actions workflow</h2>\n<p>Once again, go to your Vercel dashboard and create an access token in your account settings. Add this token as the <code>VERCEL_TOKEN</code> Github repository secret. Then, in your Vercel project settings, copy your user (organization) ID and project ID and add them as the <code>VERCEL_ORG_ID</code> and <code>VERCEL_PROJECT_ID</code> Github repository secrets.</p>\n<p>With this setup, Github is aware of your Vercel project, and <strong>NOT</strong> the other way around. Vercel only receives the compiled application artifacts and has no access to your Github repository or source code.</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/main/.github/workflows/vercel__deploy-manual.yml\">https://github.com/nemanjam/nemanjam.github.io/blob/main/.github/workflows/vercel__deploy-manual.yml</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Deploy to Vercel manually</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Docs example: https://vercel.com/kb/guide/how-can-i-use-github-actions-with-vercel</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">on</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  push</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'main'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    tags</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'v[0-9]+.[0-9]+.[0-9]+'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  pull_request</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'disabled-main'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  workflow_dispatch</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">permissions</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  contents</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">read</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">env</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # Project vars</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # Redundant, vercel pull will define them</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # SITE_URL: 'https://nemanjam.vercel.app'</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # PLAUSIBLE_DOMAIN: 'nemanjamitic.com'</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # PLAUSIBLE_SCRIPT_URL: 'https://plausible.arm1.nemanjamitic.com/js/script.js'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # Vercel vars</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  VERCEL_ORG_ID</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.VERCEL_ORG_ID }}</span><span style=\"color:#6A737D\"> # user id</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  VERCEL_PROJECT_ID</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.VERCEL_PROJECT_ID }}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">jobs</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  deploy-vercel</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    runs-on</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ubuntu-latest</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    steps</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Checkout code</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">actions/checkout@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          fetch-depth</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Print commit id and message</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#F97583\">|</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">          git show -s --format='%h %s'</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">          echo \"github.ref -> ${{ github.ref }}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Set up Node.js</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">actions/setup-node@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          node-version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">24.13.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          registry-url</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://registry.npmjs.org'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Install pnpm</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm/action-setup@v4</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">10.30.1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Install Vercel CLI</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">pnpm add -g vercel</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Pull Vercel production environment variables</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Build project using Vercel</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Deploy prebuilt project to Vercel</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        run</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}</span></span></code></pre>\n<h3 id=\"repository-secrets\">Repository secrets</h3>\n<p>Vercel’s official tutorial already does a good job of explaining the basics and provides a solid starting workflow file: <a href=\"https://vercel.com/kb/guide/how-can-i-use-github-actions-with-vercel\">https://vercel.com/kb/guide/how-can-i-use-github-actions-with-vercel</a>. In this article, we will focus on a specific use case: deploying a static website.</p>\n<p>Let’s start by explaining the Github repository secrets used in this workflow:</p>\n<ul>\n<li><code>VERCEL_TOKEN</code> - an access token that the Github Actions runner uses to authenticate with Vercel and create a deployment</li>\n<li><code>VERCEL_ORG_ID</code> - a Github user or organization ID that identifies who owns the deployment</li>\n<li><code>VERCEL_PROJECT_ID</code> - identifies the Vercel project being deployed</li>\n</ul>\n<p>The <code>VERCEL_ORG_ID</code> and <code>VERCEL_PROJECT_ID</code> values are passed as environment variables and are defined at the workflow level, making them available to all jobs. The <code>VERCEL_TOKEN</code> is passed to individual commands as a command-line argument.</p>\n<h3 id=\"set-up-nodejs-and-vercel-cli\">Set up Node.js and Vercel CLI</h3>\n<p>The first part of the workflow is standard and straightforward. We simply check out the repository (<code>fetch-depth: 1</code> to fetch only the latest commit for speed), then install Node.js, pnpm, and the Vercel CLI. These steps set up the prerequisites needed to build and deploy the project in the following steps.</p>\n<h3 id=\"environment-variables\">Environment variables</h3>\n<p>Here we are referring to <strong>your project’s</strong> environment variables. Since we are deploying a fully static website, all environment variables are strictly <strong>build-time</strong> variables, as explained here: <a href=\"https://nemanjamitic.com/blog/2025-12-21-static-website-runtime-environment-variables\">https://nemanjamitic.com/blog/2025-12-21-static-website-runtime-environment-variables</a>. The Vercel target environment does not need to define any variables because they are inlined during the build, immutable, and ignored afterward. This also means the build artifacts are specific to the environment they were built for.</p>\n<p>Although variables in the target environment are ignored at runtime, it is still a good practice to define them in the Vercel dashboard and use Vercel as the single source of truth for your deployment. This allows you to easily pull them into the Github Actions runner using: <code>vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}</code></p>\n<p>The <code>--environment=production</code> flag selects the production environment. To deploy to preview environments, you can create a separate workflow <code>.yml</code> file triggered by feature branches (any branch other than <code>main</code>) and use <code>vercel pull</code> with the <code>--environment=preview</code> option to fetch the corresponding variables.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#79B8FF\">on</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  push</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches-ignore</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">main</span></span></code></pre>\n<p><strong>Note:</strong> You can define your project’s environment variables using the <code>env:</code> key at the workflow or job level, but this is generally not recommended. Doing so will lead to conflicts with variables pulled via <code>vercel pull</code> and issues with overriding priority, unless you are confident in managing them. Relying exclusively on variables from <code>vercel pull</code> ensures clarity and simplicity.</p>\n<h3 id=\"building-and-deploying\">Building and deploying</h3>\n<p>At this point, we are ready to build the project using: <code>vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}</code>. This command generates the application artifacts in the output folder specified in <code>vercel.json</code>, all within the Github Actions runner. After that, the Vercel CLI copies the framework’s output folder (defined in <code>vercel.json</code>) inside the <code>.vercel/output</code> folder, creating a deployment-ready package that can be uploaded directly to Vercel.</p>\n<p>The final step is to upload the deployment-ready package inside the <code>.vercel/output</code> folder to Vercel using: <code>vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}</code>. The <code>--prebuilt</code> option tells Vercel to skip the build step since the application has already been built in the Github Actions runner.</p>\n<p>That’s it. Add the shown <code>vercel.json</code>, <code>.vercelignore</code>, and <code>.github/workflows/vercel__deploy-manual.yml</code> files to your repository, then run <code>git push</code> to trigger the workflow. Once it completes, you can view your website at <code>&#x3C;your-project-name>.vercel.app</code>.</p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Repository:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io\">https://github.com/nemanjam/nemanjam.github.io</a></li>\n<li><strong>Demo:</strong> <a href=\"https://nemanjam.vercel.app\">https://nemanjam.vercel.app</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> clone</span><span style=\"color:#9ECBFF\"> git@github.com:nemanjam/nemanjam.github.io.git</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Files</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.github/workflows/vercel__deploy-manual.yml</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel.json</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.vercelignore</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.dockerignore</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.gitignore</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Vercel configuration and workflow in a clear diff</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://github.com/nemanjam/nemanjam.github.io/commit/c0d6c6739b3215a6841a463115ec5242ea76e492</span></span></code></pre>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>CI/CD workflows are the standard way to handle deployments, and deploying to Vercel is no exception. By combining Github Actions with the Vercel CLI, you can implement a fully automated deployment pipeline with just a few lines of configuration.</p>\n<p>This approach gives you complete control over the build and deployment process while keeping your source code private and your security model explicit. Once in place, deployments become predictable, repeatable, and hands-off.</p>\n<p>How do you automate deployments to Vercel in your projects? Let me know in the comments.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Github Actions with Vercel, Vercel official tutorial <a href=\"https://vercel.com/kb/guide/how-can-i-use-github-actions-with-vercel\">https://vercel.com/kb/guide/how-can-i-use-github-actions-with-vercel</a></li>\n<li>Astro on Vercel, Vercel docs <a href=\"https://vercel.com/docs/frameworks/frontend/astro\">https://vercel.com/docs/frameworks/frontend/astro</a></li>\n<li>Github integration, Vercel docs <a href=\"https://vercel.com/docs/git/vercel-for-github\">https://vercel.com/docs/git/vercel-for-github</a></li>\n<li><code>vercel deploy --prebuilt</code> option, Vercel docs <a href=\"https://vercel.com/docs/cli/deploy#prebuilt\">https://vercel.com/docs/cli/deploy#prebuilt</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2026-02-26-vercel-static-github-actions/",
            "title": "Automating the deployment of a static website to Vercel with Github Actions",
            "summary": "Use Github Actions and the Vercel CLI to automate the deployment of a static website to Vercel.\n",
            "date_modified": "2026-02-26T00:00:00.000Z",
            "date_published": "2026-02-26T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2026-02-22-vercel-deploy-fastapi-nextjs/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;\nimport DeploymentDiagramImage from ’../../../../content/post/2026/02-22-vercel-deploy-fastapi-nextjs/_images/vercel-fastapi-nextjs.png’;\nimport FrontendScreenshotImage from ’../../../../content/post/2026/02-22-vercel-deploy-fastapi-nextjs/_images/frontend-screenshot-1200x630.png’;\nimport BackendScreenshotImage from ’../../../../content/post/2026/02-22-vercel-deploy-fastapi-nextjs/_images/backend-screenshot-1200x630.png’;\nimport VercelButtonWizardScreenshotImage from ’../../../../content/post/2026/02-22-vercel-deploy-fastapi-nextjs/_images/vercel-button-wizard-screenshot.png’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>This article is a practical guide that presents a single, proven approach for deploying a FastAPI and Next.js application on Vercel. It is not intended to be a comprehensive, in depth overview of all Vercel features or deployment options. The main idea is to show how to host demo apps on Vercel for free.</p>\n<p>Python is one of the runtimes with first-class support on Vercel, as stated in the documentation: <a href=\"https://vercel.com/docs/functions/runtimes\">https://vercel.com/docs/functions/runtimes</a>. Additionally, FastAPI is an officially supported backend framework and is documented here: <a href=\"https://vercel.com/docs/frameworks/backend/fastapi\">https://vercel.com/docs/frameworks/backend/fastapi</a>.</p>\n<p>Naturally, Next.js is developed by Vercel and has full, native support on the platform. Given all of this, deploying a full-stack FastAPI and Next.js application on Vercel is entirely viable, and we can confidently proceed with the configuration and deployment.</p>\n<h2 id=\"project-structure-prerequisites\">Project structure prerequisites</h2>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={DeploymentDiagramImage} alt=“Deployment diagram” /></p>\n<p>There are certain constraints on how a FastAPI and Next.js project must be structured and configured in order to be deployable on Vercel. When deploying with Docker, we typically use separate containers for the backend and frontend, since a container is designed to run a single process. Similarly, on Vercel we will use two separate deployments, one for the backend and one for the frontend, and connect them using the <code>SITE_URL</code> and <code>API_URL</code> environment variables.</p>\n<p>In general, a full-stack application should be designed without making assumptions about the values of <code>SITE_URL</code> and <code>API_URL</code>, allowing the backend and frontend to be hosted independently on any arbitrary domains.</p>\n<p>This is especially important for authentication and cookies, since the frontend and backend will use different <code>*.vercel.app</code> domains. Because these domains are included in the public suffix list (<a href=\"https://publicsuffix.org/list\">https://publicsuffix.org/list</a>), cookie behavior cannot be fully controlled across them.</p>\n<p>One effective solution is to enforce a clean separation of concerns: use FastAPI for authentication logic, and rely on Next.js API routes or server actions for setting and unsetting cookies. I covered this approach in detail in a previous article: <a href=\"https://nemanjamitic.com/blog/2026-02-07-github-login-fastapi-nextjs#architecture-overview\">https://nemanjamitic.com/blog/2026-02-07-github-login-fastapi-nextjs#architecture-overview</a>.</p>\n<p><strong>Note:</strong> If your project originally hosts the backend and frontend on the same domain and relies on a reverse proxy such as Nginx or Traefik for routing, you will need to reconfigure this setup, as Vercel does not provide that level of routing control.</p>\n<h2 id=\"configuration\">Configuration</h2>\n<p>Vercel offers two basic ways to deploy a project: 1. using the CLI to deploy from a local machine, and 2. deploying from a repository URL, such as Github or Gitlab. There are also several variations of these approaches, including the “Vercel button” which launches a setup wizard from a repository URL, and Github Actions, where the CLI is used inside a workflow runner.</p>\n<p>In this tutorial, we will use the CLI approach and deploy both the backend and frontend directly from our local development machine.</p>\n<p>We will begin by installing the Vercel CLI globally on our local machine and then logging in with our Vercel account.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Install Vercel CLI</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">pnpm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#79B8FF\"> -g</span><span style=\"color:#9ECBFF\"> vercel</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log in to Vercel</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> login</span></span></code></pre>\n<h2 id=\"configuring-the-fastapi-backend\">Configuring the FastAPI backend</h2>\n<h3 id=\"verceljson\">vercel.json</h3>\n<p>First, we need to expose a FastAPI entry point in a way that can be consumed by a Vercel serverless function.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/app/api/index.py\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/app/api/index.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Required by vercel.json to locate the FastAPI `app` instance.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># `# noqa: F401` prevents formatters from removing this import.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">from</span><span style=\"color:#E1E4E8\"> app.main </span><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> app  </span><span style=\"color:#6A737D\"># noqa: F401</span></span></code></pre>\n<p>Then we can use it in <code>vercel.json</code> to define the build path for the serverless function and the root request handler for HTTP requests.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/vercel.json\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/vercel.json</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"builds\"</span><span style=\"color:#E1E4E8\">: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"src\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"app/api/index.py\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"use\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"@vercel/python\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"routes\"</span><span style=\"color:#E1E4E8\">: [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"src\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"/(.*)\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      \"dest\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"app/api/index.py\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"vercelignore\">.vercelignore</h3>\n<p>Next, we need to define the backend’s root project directory and specify which files should be included or excluded from the serverless function. This step is important to avoid unnecessary bloat, as it affects both performance and the 250 MB size limit for Vercel functions on the free plan. By default, the root project directory is the folder where you run the <code>vercel</code> CLI command, though you can override it during the deployment prompt.</p>\n<p>The <code>.vercelignore</code> file is used to exclude files from being uploaded during deployment. It should be placed inside the root project directory. If you already have a properly configured <code>.dockerignore</code>, these two files are basically the same. Below is my <code>.vercelignore</code> configuration:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/.vercelignore\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/.vercelignore</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Python</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.venv/</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">__pycache__/</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.ruff_cache/</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.mypy_cache/</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.pytest_cache/</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">app.egg-info</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">*</span><span style=\"color:#E1E4E8\">.pyc</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Tests</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.coverage</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">htmlcov</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">Dockerfile*</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ignore database</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">!</span><span style=\"color:#B392F0\">data/database</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">data/database/*</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">!</span><span style=\"color:#B392F0\">data/database/.gitkeep</span></span></code></pre>\n<p>You can also verify and debug any redundant files in the Vercel dashboard under <code>My Project -> My Deployment -> Source</code>. I highly recommend performing this check to ensure that no unnecessary bloat is included in the serverless function.</p>\n<p>Additionally, add the <code>backend/.vercel/</code> directory to your <code>.gitignore</code> file. This directory contains local Vercel configuration and should not be committed to Git.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Local Vercel configuration</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.vercel/</span></span></code></pre>\n<h3 id=\"environment-variables\">Environment variables</h3>\n<p>Your application already relies on certain environment variables that are configured in a specific way. However, their names and formats may not fully align with the predefined variables available in the Vercel environment, so some adaptation is usually required. In my case, this involved the following changes:</p>\n<ol>\n<li>I had an <code>ENVIRONMENT</code> variable that needed to be derived from the predefined <code>VERCEL_ENV</code> variable and then reassigned using a Pydantic model validator.</li>\n<li>For the database configuration, I originally used five separate variables: <code>POSTGRES_SERVER</code>, <code>POSTGRES_PORT</code>, <code>POSTGRES_USER</code>, <code>POSTGRES_PASSWORD</code>, and <code>POSTGRES_DB</code>. These were replaced with a single <code>DATABASE_URL</code> variable, which is exposed by Neon by default. This value can then be piped into a <code>SQLALCHEMY_DATABASE_URI</code> computed property.</li>\n</ol>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/app/core/config.py\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/backend/app/core/config.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Vercel default vars</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">str</span><span style=\"color:#F97583\"> |</span><span style=\"color:#79B8FF\"> None</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># for Vercel and Neon</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">DATABASE_URL</span><span style=\"color:#E1E4E8\">: PostgresDsn </span><span style=\"color:#F97583\">|</span><span style=\"color:#79B8FF\"> None</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Local / Docker fallback</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">POSTGRES_SERVER</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">str</span><span style=\"color:#F97583\"> |</span><span style=\"color:#79B8FF\"> None</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">POSTGRES_PORT</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">int</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> 5432</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">POSTGRES_USER</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">str</span><span style=\"color:#F97583\"> |</span><span style=\"color:#79B8FF\"> None</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">POSTGRES_PASSWORD</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">str</span><span style=\"color:#F97583\"> |</span><span style=\"color:#79B8FF\"> None</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">POSTGRES_DB</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">str</span><span style=\"color:#F97583\"> |</span><span style=\"color:#79B8FF\"> None</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">@computed_field</span><span style=\"color:#6A737D\">  # type: ignore[prop-decorator]</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">@</span><span style=\"color:#79B8FF\">property</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> SQLALCHEMY_DATABASE_URI</span><span style=\"color:#E1E4E8\">(self) -> PostgresDsn:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Vercel + Neon</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#79B8FF\"> self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">DATABASE_URL</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        database_url </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> str</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">DATABASE_URL</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Force SQLAlchemy to use psycopg v3 on Vercel (Neon provides postgresql:// by default)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> database_url.startswith(</span><span style=\"color:#9ECBFF\">\"postgresql://\"</span><span style=\"color:#E1E4E8\">):</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            database_url </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> database_url.replace(</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">                \"postgresql://\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"postgresql+psycopg://\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            )</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#E1E4E8\"> database_url</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Local / Docker</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#79B8FF\"> all</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        [</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_SERVER</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_USER</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_PASSWORD</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_DB</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        ]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ):</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#79B8FF\"> ValueError</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            \"Either DATABASE_URL (Vercel/Neon) or POSTGRES_* variables must be set\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        )</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> MultiHostUrl.build(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        scheme</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"postgresql+psycopg\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        username</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_USER</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        password</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_PASSWORD</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        host</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_SERVER</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        port</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_PORT</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        path</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POSTGRES_DB</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    )</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">@model_validator</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">mode</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"after\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> resolve_environment</span><span style=\"color:#E1E4E8\">(self) -> Self:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#79B8FF\"> self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#F97583\"> ==</span><span style=\"color:#9ECBFF\"> \"production\"</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">ENVIRONMENT</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> \"production\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    elif</span><span style=\"color:#79B8FF\"> self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">VERCEL_ENV</span><span style=\"color:#F97583\"> ==</span><span style=\"color:#9ECBFF\"> \"preview\"</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        self</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">ENVIRONMENT</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> \"staging\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # else: keep whatever ENVIRONMENT was set from OS/.env</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#79B8FF\"> self</span></span>\n<span class=\"line\"></span></code></pre>\n<p>As already noted, <code>SITE_URL</code> is a particularly important variable, as it is the primary way to inform the backend about the domain on which the frontend is hosted. It is used throughout the backend for CORS whitelisting, redirects, constructing absolute URLs, etc.</p>\n<p>You may not have this value available during the initial backend deployment. In that case, you can temporarily set a placeholder value to satisfy Pydantic validation. Once the actual frontend URL is known, update the variable via the Vercel dashboard or CLI and redeploy the application for the change to take effect.</p>\n<p>Below is the full list of required and optional environment variables used by this backend application:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/.env.vercel.example\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/.env.vercel.example</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ------------ Required vars --------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Frontend url</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Used by the backend to generate links in emails to the frontend</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SITE_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">my-frontend-url.vercel.app</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Auth</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">JWT_SECRET_KEY</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">my-secret</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SESSION_SECRET_KEY</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">my-secret</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Superuser email and password</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">FIRST_SUPERUSER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">admin@example.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">FIRST_SUPERUSER_PASSWORD</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">password</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Postgres database, e.g. Neon</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Format: postgresql://&#x3C;username>:&#x3C;password>@&#x3C;host>/&#x3C;database>?&#x3C;query></span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Neon example: postgresql://neondb_owner:npg_someHash@ep-solitary-moon-some-hash-pooler.c-3.us-east-1.aws.neon.tech/neondb?sslmode=require</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DATABASE_URL</span><span style=\"color:#F97583\">=</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ------------ Optional vars --------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Used in email templates and OpenAPI docs</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PROJECT_NAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Full stack FastAPI template Next.js\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Whitelisted frontend urls</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># SITE_URL is included by default</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BACKEND_CORS_ORIGINS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"http://localhost,https://localhost,http://localhost:3000,https://localhost:3000,http://localhost:3001,https://localhost:3001,https://my-frontend-url.vercel.app\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Environment: local, staging, production</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># If omitted defaults to VERCEL_ENV</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">ENVIRONMENT</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># If omitted defaults to 7 days = 24 * 7 = 168 hours</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">ACCESS_TOKEN_EXPIRE_HOURS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">168</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Github OAuth id and secret</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Only a single deployment (callback url) per Github app is possible</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">GITHUB_CLIENT_ID</span><span style=\"color:#F97583\">=</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">GITHUB_CLIENT_SECRET</span><span style=\"color:#F97583\">=</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Postgres database, e.g. Neon</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Supply either all POSTGRES_* variables or a single DATABASE_URL</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># If both are defined DATABASE_URL has precedence</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">POSTGRES_SERVER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">localhost</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">POSTGRES_PORT</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">5432</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">POSTGRES_DB</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">app</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">POSTGRES_USER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">postgres</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">POSTGRES_PASSWORD</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">password</span></span></code></pre>\n<h3 id=\"database-migrations-and-seed\">Database migrations and seed</h3>\n<p>At this point, the only remaining step is to obtain a valid <code>DATABASE_URL</code> that points to a migrated, seeded, and running database instance. You can use any cloud or self-hosted PostgreSQL instance, as long as it is accessible over the internet. In this case, we will use Neon, since it is an officially supported PostgreSQL integration on Vercel.</p>\n<p>Create an account at <a href=\"https://neon.com\">https://neon.com</a>, then create a new project and a new database. Next, connect the database to your Vercel project using the Neon integration and use the default <code>production</code> database branch. When the integration is added, Neon will automatically expose certain environment variables to your Vercel deployment, which you can customize in the Neon integration settings. The <code>DATABASE_URL</code> variable is exposed by default, and since we configured the backend to use it in the previous step, no further action is required at this stage.</p>\n<p>At this point, the backend can connect to the database, but the database is still empty. We now need to run migrations to create the tables and seed the initial data.</p>\n<p><strong>Note:</strong> You might be tempted to automate database migrations and seeding (especially for demo apps) by running them on application startup. However, since Vercel is a serverless environment, there is no single, well-defined application start event (unlike a VPS or Docker-based setup). Instead, multiple instances can start independently, which would cause migrations and seeds to run multiple times in an unpredictable manner. This can lead to serious issues. For this reason, database migrations and initial seeding should be performed as a single, manual step.</p>\n<p>We will use our local development environment, where the application is already fully installed and configured, to run the migrations and seed the data. In the local <code>.env</code> file, simply replace the local development database connection with the remote production database connection that is already used by the Vercel deployment.</p>\n<p>Also, make sure to check out the <code>vercel-deploy</code> branch locally, as only that branch is configured to handle the <code>DATABASE_URL</code> variable correctly.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Checkout vercel-deploy branch that has Vercel configuration (backend/vercel.json, backend/.vercelignore, backend/app/api/index.py, modified backend/app/core/config.py)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> vercel-deploy</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Comment out local Postgres database</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># POSTGRES_SERVER=localhost</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># POSTGRES_PORT=5433</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># POSTGRES_DB=app</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># POSTGRES_USER=postgres</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># POSTGRES_PASSWORD=password</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Neon database url example used on Vercel:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">DATABASE_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">postgresql://neondb_owner:npg_some-slug@ep-rough-cherry-some-slug-pooler.c-3.us-east-1.aws.neon.tech/neondb?</span><span style=\"color:#E1E4E8\">sslmode</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">require</span></span></code></pre>\n<p>Now we can run the migrations and seed the Neon remote database. Make sure that all backend dependencies are installed and that your Python virtual environment is activated. Be patient and allow the command to complete, as this process can take a few minutes.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># From /backend</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">cd</span><span style=\"color:#9ECBFF\"> ./backend</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Create virtual environment</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">uv</span><span style=\"color:#9ECBFF\"> venv</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Activate the environment</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">source</span><span style=\"color:#9ECBFF\"> .venv/bin/activate</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Install dependencies</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">uv</span><span style=\"color:#9ECBFF\"> sync</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Migration and seed scripts need activated venv and Python dependencies</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Await db, run migrations and seed (must have .env)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># With a remote Neon database, this command can take a few minutes to complete</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Be patient and do not interrupt it</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">bash</span><span style=\"color:#9ECBFF\"> scripts/prestart.sh</span></span></code></pre>\n<p>Open the Neon dashboard and verify that the <code>user</code> and <code>item</code> tables have been created and populated with the seed data. At this point, the backend is fully configured and ready to be deployed using the Vercel CLI.</p>\n<h3 id=\"deploying-backend-from-terminal\">Deploying backend from terminal</h3>\n<p>First, install the <code>vercel</code> CLI and log in with your Vercel account. Then navigate to the <code>backend</code> directory and start the deployment wizard by running the <code>vercel --prod</code> command. The wizard will prompt you to:</p>\n<ul>\n<li>Link an existing Vercel project or create and name a new one (this determines the app’s public URL)</li>\n<li>Select the project root directory - choose the current directory (<code>./</code>)</li>\n<li>Set environment variables - this step can be skipped for now</li>\n<li>Modify the build configuration - select “No”</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Install Vercel CLI</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">pnpm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#79B8FF\"> -g</span><span style=\"color:#9ECBFF\"> vercel</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log in to Vercel</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> login</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Navigate to the backend folder</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">cd</span><span style=\"color:#9ECBFF\"> backend</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Deploy for the first time (production)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Fill prompts for name, root directory `./` (vercel.json dir)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#79B8FF\"> --prod</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Add required environment variables (production) (after the wizard completes)</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"Full stack FastAPI template Next.js\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> PROJECT_NAME</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"https://my-frontend-url.vercel.app\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> SITE_URL</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"my-secret\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> JWT_SECRET_KEY</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"my-secret\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> SESSION_SECRET_KEY</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"admin@example.com\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> FIRST_SUPERUSER</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"password\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> FIRST_SUPERUSER_PASSWORD</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"postgresql://user:pass@host/db\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> DATABASE_URL</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Set more optional variables...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># List existing environment variables</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> ls</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Redeploy after changes</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#79B8FF\"> --prod</span><span style=\"color:#6A737D\">  # production</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># After deploy, the CLI outputs the URL</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Example: https://api-full-stack-fastapi-template-nextjs-my-slug.vercel.app</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Debug deployment</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> inspect</span><span style=\"color:#9ECBFF\"> https://api-full-stack-fastapi-template-nextjs-my-slug.vercel.app</span><span style=\"color:#79B8FF\"> --json</span></span></code></pre>\n<p>Treat the initial deployment as disposable. At this stage, the frontend is likely not deployed yet, which means the <code>SITE_URL</code> value is not available. You can temporarily set a placeholder URL that satisfies Pydantic validation. Once the frontend is deployed, update <code>SITE_URL</code> with the actual value using the CLI (as shown earlier) or via the Vercel dashboard (<code>Project -> Settings -> Environment Variables</code>).</p>\n<p>The same approach applies to other environment variables, such as <code>GITHUB_CLIENT_ID</code> and <code>GITHUB_CLIENT_SECRET</code>. After updating any environment variable, you must redeploy the project for the changes to take effect.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Update (remove and add) an existing env var SITE_URL</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> rm</span><span style=\"color:#9ECBFF\"> SITE_URL</span><span style=\"color:#9ECBFF\"> production</span><span style=\"color:#79B8FF\"> --yes</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"https://my-new-frontend-url.vercel.app\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> SITE_URL</span><span style=\"color:#9ECBFF\"> production</span></span></code></pre>\n<p>Once the deployment wizard completes successfully, you can verify the result by accessing the FastAPI backend at the URL printed in the terminal, for example: <code>https://api-full-stack-fastapi-template-nextjs-my-slug.vercel.app/docs</code>.</p>\n<p>This will display the OpenAPI UI. The exact URL depends on how you named the project. You can view all associated URLs in the Vercel dashboard under the deployment settings.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={BackendScreenshotImage} alt=“OpenAPI screenshot” /></p>\n<h2 id=\"configuring-the-nextjs-frontend\">Configuring the Next.js frontend</h2>\n<p>Deploying the Next.js application is much simpler, as it requires fewer environment variables and no database. However, there are still a few details to keep in mind, which we will cover here.</p>\n<h3 id=\"verceljson-1\">vercel.json</h3>\n<p>We use a <code>vercel.json</code> file to specify the framework, install and build commands, and the output directory. These settings mirror the local development environment and are pretty self-explanatory.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/vercel.json\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/vercel.json</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"framework\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"nextjs\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"installCommand\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"pnpm install\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"buildCommand\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"pnpm turbo run build --filter=web\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"outputDirectory\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\".next\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Note that our Next.js application uses a monorepo setup with Turbo, which has a few implications:</p>\n<ul>\n<li>The <code>vercel.json</code> file <strong>is NOT</strong> placed at the monorepo root (<code>frontend/</code>). Instead, it lives in the Next.js application directory at <code>frontend/apps/web/vercel.json</code>. Despite this, the Vercel project root directory <strong>IS</strong> the monorepo root (<code>frontend/</code>), since all packages and source files must be uploaded in order to build the application successfully. This is the correct approach for deploying monorepo projects to Vercel.</li>\n<li>The <code>.vercelignore</code> file should also be placed in the monorepo root directory (<code>frontend/</code>).</li>\n</ul>\n<h3 id=\"vercelignore-1\">.vercelignore</h3>\n<p>As with the backend, we need to ignore all unused local files during deployment to prevent unnecessary uploads, performance degradation, and excess bloat, and to stay below the 250 MB limit for a serverless function. Once again, it is a good idea to verify that nothing was missed by checking <code>My Project -> My Deployment -> Source</code> in the Vercel dashboard.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/.vercelignore\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/.vercelignore</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Node.js</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/node_modules/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/.next/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/.turbo/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/dist/</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Ignore all env files, env vars are passed into container explicitly</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/.env</span><span style=\"color:#F97583\">*</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Tests</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/test-results/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/playwright-report/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/blob-report/</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">/playwright/.cache/</span></span></code></pre>\n<p>Similarly, add the <code>frontend/.vercel/</code> directory to your frontend <code>.gitignore</code> file. This directory contains local Vercel configuration and should not be committed to Git.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/.gitignore\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/.gitignore</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Local Vercel configuration</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.vercel/</span></span></code></pre>\n<h3 id=\"environment-variables-1\">Environment variables</h3>\n<p>The frontend uses far fewer environment variables, only two in fact: <code>API_URL</code>, which points to the backend URL, and <code>SITE_URL</code>, which represents the frontend’s own URL. The value of <code>SITE_URL</code> can be derived from the predefined <code>VERCEL_PROJECT_PRODUCTION_URL</code> variable exposed by Vercel.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/.env.vercel.example\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/.env.vercel.example</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># ------------ Required vars --------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Backend url</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">API_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://my-backend-url.vercel.app</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ------------ Optional vars --------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Frontend url</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># If omitted defaults to https:// + VERCEL_PROJECT_PRODUCTION_URL</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SITE_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://my-frontend-url.vercel.app</span></span></code></pre>\n<p>With this in mind, we need to include <code>VERCEL_PROJECT_PRODUCTION_URL</code> in the Next.js environment variable handling logic.</p>\n<p>Set it as a fallback value in the <code>next-public-env</code> schema.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/src/config/process-env.ts\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/src/config/process-env.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">getPublicEnv</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">PublicEnv</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> createPublicEnv</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    NODE_ENV: process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SITE_URL: process.env.</span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#9ECBFF\"> `https://${</span><span style=\"color:#E1E4E8\">process</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">env</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">VERCEL_PROJECT_PRODUCTION_URL</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    API_URL: process.env.</span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  { </span><span style=\"color:#B392F0\">schema</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">z</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#B392F0\"> getProcessEnvSchemaProps</span><span style=\"color:#E1E4E8\">(z) }</span></span></code></pre>\n<p>Include it in the <code>process.env</code> type.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/src/env.d.ts\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/apps/web/src/env.d.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">declare</span><span style=\"color:#F97583\"> namespace</span><span style=\"color:#B392F0\"> NodeJS</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  interface</span><span style=\"color:#B392F0\"> ProcessEnv</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    readonly</span><span style=\"color:#FFAB70\"> NODE_ENV</span><span style=\"color:#F97583\">:</span><span style=\"color:#9ECBFF\"> 'development'</span><span style=\"color:#F97583\"> |</span><span style=\"color:#9ECBFF\"> 'production'</span><span style=\"color:#F97583\"> |</span><span style=\"color:#9ECBFF\"> 'test'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    readonly</span><span style=\"color:#FFAB70\"> SITE_URL</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    readonly</span><span style=\"color:#FFAB70\"> API_URL</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    readonly</span><span style=\"color:#FFAB70\"> VERCEL_PROJECT_PRODUCTION_URL</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Notify Turborepo.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/turbo.json\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/vercel-deploy/frontend/turbo.json</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"$schema\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"https://turbo.build/schema.json\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"ui\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"tui\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"globalEnv\"</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#9ECBFF\">\"NODE_ENV\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"SITE_URL\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"API_URL\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"NEXT_RUNTIME\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">\"VERCEL_PROJECT_PRODUCTION_URL\"</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>That’s it. Our Next.js frontend is ready for deployment.</p>\n<h3 id=\"deploying-frontend-from-terminal\">Deploying frontend from terminal</h3>\n<p>Deploying the frontend is very similar to deploying the backend, with the main difference being the selection of the project root directory, since we are deploying a monorepo. Navigate to the <code>frontend</code> directory and start the deployment wizard by running the <code>vercel --prod</code> command. The wizard will prompt you to:</p>\n<ul>\n<li>Link an existing Vercel project or create and name a new one (this determines the app’s public URL)</li>\n<li>Select the project root directory - <strong>choose the Next.js app directory</strong> (<code>./apps/web/</code>)</li>\n<li>Set environment variables - this step can be skipped for now</li>\n<li>Modify the build configuration - select “No”</li>\n</ul>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Install Vercel CLI</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">pnpm</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#79B8FF\"> -g</span><span style=\"color:#9ECBFF\"> vercel</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Log in to Vercel</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> login</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Navigate to the frontend folder</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">cd</span><span style=\"color:#9ECBFF\"> frontend</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Deploy for the first time (production)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Fill prompts for name, root directory `./apps/web/` (vercel.json dir)</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#79B8FF\"> --prod</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Set required environment variables (production)</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"https://api-full-stack-fastapi-template-nextjs-my-slug.vercel.app\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> API_URL</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Set more optional variables...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># List existing environment variables</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> ls</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Redeploy after changes</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#79B8FF\"> --prod</span><span style=\"color:#6A737D\">  # production</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># After deploy, the CLI outputs the URL</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Example: https://full-stack-fastapi-template-nextjs-my-slug.vercel.app</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Debug deployment</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> inspect</span><span style=\"color:#9ECBFF\"> https://full-stack-fastapi-template-nextjs-my-slug.vercel.app</span><span style=\"color:#79B8FF\"> --json</span></span></code></pre>\n<p>Make sure to set the environment variables <code>API_URL</code> and <code>SITE_URL</code> (optional), either through the Vercel dashboard (<code>Project -> Settings -> Environment Variables</code>) or via the CLI. If <code>SITE_URL</code> is not set, it will fall back to the predefined <code>VERCEL_PROJECT_PRODUCTION_URL</code> variable, as explained earlier.</p>\n<p>Remember to redeploy the project whenever you update environment variables to apply the changes.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Update (remove and add) an existing env vars API_URL and SITE_URL</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> rm</span><span style=\"color:#9ECBFF\"> API_URL</span><span style=\"color:#9ECBFF\"> production</span><span style=\"color:#79B8FF\"> --yes</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"https://api-full-stack-fastapi-template-nextjs-my-slug.vercel.app\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> API_URL</span><span style=\"color:#9ECBFF\"> production</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> rm</span><span style=\"color:#9ECBFF\"> SITE_URL</span><span style=\"color:#9ECBFF\"> production</span><span style=\"color:#79B8FF\"> --yes</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">echo</span><span style=\"color:#9ECBFF\"> \"https://full-stack-fastapi-template-nextjs-my-slug.vercel.app\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> vercel</span><span style=\"color:#9ECBFF\"> env</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> ITE_UR</span><span style=\"color:#9ECBFF\"> production</span></span></code></pre>\n<p>If everything is configured correctly, your full-stack FastAPI and Next.js application should now be fully deployed and functional at the URL displayed in the terminal: <code>https://full-stack-fastapi-template-nextjs-my-slug.vercel.app</code></p>\n<p>The exact URL will depend on the name you chose for the project. Congratulations!</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={FrontendScreenshotImage} alt=“Frontend screenshot” /></p>\n<h2 id=\"vercel-button\">Vercel button</h2>\n<p>In addition to the CLI, Vercel also allows deploying a project by simply specifying the repository URL. Naturally, the repository must be properly prepared and configured beforehand. One variation of this method is the “Vercel Deploy” button, a URL-encoded <code>href</code> link that points to Vercel and includes query parameters for the repository URL, environment variables, integrations, and other necessary configuration details. When clicked, it launches a setup wizard to complete any remaining deployment configuration.</p>\n<p>Vercel also provides a utility form to generate these buttons conveniently: <a href=\"https://vercel.com/docs/deploy-button\">https://vercel.com/docs/deploy-button</a>. The “Vercel Deploy” button can be embedded in markdown or HTML pages, allowing visitors to deploy a live demo of your project quickly and effortlessly.</p>\n<p>With this in mind, we can define a single “Vercel Deploy” button to deploy both the backend and frontend of our FastAPI and Next.js project. It will clone a single Github repository and create two separate Vercel projects. We can then include the button’s Markdown in the project’s <code>README.md</code> file.</p>\n<p>The button below specifies URLs (repository, demo, images, etc.) for this particular example,  you can adjust them to match your own URLs.</p>\n<h3 id=\"single-monorepo-vercel-button\">Single monorepo Vercel button</h3>\n<p>This is fairly self-explanatory, but let’s clarify the query parameters for completeness:</p>\n<ul>\n<li><code>repository-url</code> - points to the Github repository and the <code>vercel-deploy</code> branch.</li>\n<li><code>repository-name</code> - the name of the cloned repository.</li>\n<li><code>root-directories</code> - specifies the backend (<code>backend</code>) and frontend root directories (<code>frontend/apps/web</code>).</li>\n<li><code>monorepo</code> - indicates that a single Git repository contains multiple Vercel projects.</li>\n<li><code>totalProjects</code> - number of Vercel projects to create.</li>\n</ul>\n<p>Additional parameters include:</p>\n<ul>\n<li><code>project-names</code> - the names the backend and frontend Vercel projects.</li>\n<li><code>demo-*</code> - information and URLs displayed in the wizard for demo purposes.</li>\n</ul>\n<p>The <code>products</code> parameter is particularly important, as it activates the Neon integration in the wizard to provision a new PostgreSQL database.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># URL for the \"Vercel Deploy\" button's href attribute</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://vercel.com/new/clone</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\">repository-url</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/vercel-deploy</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;root-directories</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">frontend/apps/web,backend</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;repository-name</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">full-stack-fastapi-template-with-next-js</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;monorepo</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;totalProjects</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">2</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;project-names</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">full-stack-fastapi-frontend,full-stack-fastapi-backend</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;demo-description</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">Build</span><span style=\"color:#B392F0\"> full-stack</span><span style=\"color:#9ECBFF\"> apps</span><span style=\"color:#9ECBFF\"> with</span><span style=\"color:#9ECBFF\"> Next.js</span><span style=\"color:#9ECBFF\"> and</span><span style=\"color:#9ECBFF\"> FastAPI.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;demo-image</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/raw/main/docs/screenshots/frontend-screenshot-1200x630.png</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;demo-title</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">Full</span><span style=\"color:#B392F0\"> stack</span><span style=\"color:#9ECBFF\"> FastAPI</span><span style=\"color:#9ECBFF\"> template</span><span style=\"color:#9ECBFF\"> with</span><span style=\"color:#9ECBFF\"> Next.js</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;demo-url</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://full-stack-fastapi-template-nextjs.vercel.app</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;skippable-integrations</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x26;products</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">[</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    \"type\"</span><span style=\"color:#79B8FF\">:</span><span style=\"color:#9ECBFF\"> \"integration\",</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    \"integrationSlug\"</span><span style=\"color:#79B8FF\">:</span><span style=\"color:#9ECBFF\"> \"neon\",</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    \"productSlug\"</span><span style=\"color:#79B8FF\">:</span><span style=\"color:#9ECBFF\"> \"neon\",</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    \"protocol\"</span><span style=\"color:#79B8FF\">:</span><span style=\"color:#9ECBFF\"> \"storage\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">]</span></span></code></pre>\n<p>Once the URL is constructed, it should be URL-encoded and then embedded in the markdown (or HTML) as shown below.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"md\"><code><span class=\"line\"><span style=\"color:#6A737D\">&#x3C;!-- Markdown for the button --></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#DBEDFF;text-decoration:underline\">![Deploy backend to Vercel](https://vercel.com/button)</span><span style=\"color:#E1E4E8\">](</span><span style=\"color:#E1E4E8;text-decoration:underline\">urlencoded-url-from-above</span><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>The button and wizard will clone a single GitHub repository and create and deploy two separate backend and frontend Vercel projects. The deployments are not functional at this point. The wizard <strong>will not set any required environment variables</strong> for either the backend or frontend projects. Users will need to set the appropriate environment variables themselves in the Vercel dashboard.</p>\n<p>The wizard will also create an integration that <strong>provisions an unassigned, blank Neon database</strong>. Users will then need to <strong>assign the integration to the backend project</strong> and run migrations and seed the database manually, as described in the previous section: <a href=\"#database-migrations-and-seed\">Database migrations and seed</a>.</p>\n<p>After clicking the “Vercel Deploy” button, the user will be taken to a form wizard, as shown below.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={VercelButtonWizardScreenshotImage} alt=“Vercel button wizard” /></p>\n<p><strong>Note:</strong> You can also use two separate “Vercel Deploy” buttons to clone the backend and frontend as two Github repositories (and deploy them as two Vercel projects). This approach allows you to include the list of environment variables and their default values (<code>env</code>, <code>envDefaults</code>, and <code>envDescription</code> parameters) in the button’s URL.</p>\n<p>You can see examples of such buttons at the following links: <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/5f8dff480c8e5c2901706891ff0ec604c77d00db/docs/notes/vercel/vercel-button-backend.md\">vercel-button-backend.md</a>, <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/5f8dff480c8e5c2901706891ff0ec604c77d00db/docs/notes/vercel/vercel-button-frontend.md\">vercel-button-frontend.md</a></p>\n<h2 id=\"completed-code-and-demo\">Completed code and demo</h2>\n<ul>\n<li><strong>Repository and branch:</strong> <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/vercel-deploy\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/vercel-deploy</a></li>\n<li><strong>Demo frontend:</strong> <a href=\"https://full-stack-fastapi-template-nextjs.vercel.app\">https://full-stack-fastapi-template-nextjs.vercel.app</a></li>\n<li><strong>Demo backend:</strong> <a href=\"https://api-full-stack-fastapi-template-nextjs.vercel.app/docs\">https://api-full-stack-fastapi-template-nextjs.vercel.app/docs</a></li>\n</ul>\n<p>The relevant branch <code>vercel-deploy</code> and files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> clone</span><span style=\"color:#9ECBFF\"> git@github.com:nemanjam/full-stack-fastapi-template-nextjs.git</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Checkout the vercel-deploy branch</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> vercel-deploy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Backend</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/vercel.json</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/.vercelignore</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/api/index.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/core/config.py</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">.env.vercel.example</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">docs/notes/vercel-deployment-backend.md</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Frontend</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/vercel.json</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/.vercelignore</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/config/process-env.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/env.d.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/turbo.json</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/.env.vercel.example</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">docs/notes/vercel-deployment-frontend.md</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Compare branches in a clear diff</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/compare/vercel-deploy?expand</span><span style=\"color:#9ECBFF\">=1</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Compare specific commits in a clear diff</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/compare/45c840d48cba2aeab07e0a66f8245110b852571e...e5fa4b4af3c19c8c2c584fe437b7298f9e342083</span></span></code></pre>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>Vercel is a quite viable option for hosting full-stack FastAPI and Next.js demo projects for free. It works well when the project is designed around serverless constraints. Keeping the backend and frontend as separate deployments, using environment variables consistently, and handling database migrations manually results in a setup that is simple, predictable, and reliable on the free tier.</p>\n<p>This guide focused on one practical approach that is easy to reproduce and debug. With the provided repository and Vercel Deploy buttons, you can use this setup as a solid baseline for demos, prototypes, or small production projects.</p>\n<p>Have you done something similar yourself and used a different approach? Leave a comment below, I’m happy to hear your opinions.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Vercel docs, supported runtimes <a href=\"https://vercel.com/docs/functions/runtimes\">https://vercel.com/docs/functions/runtimes</a></li>\n<li>Vercel docs, supported backend frameworks <a href=\"https://vercel.com/docs/frameworks/backend/fastapi\">https://vercel.com/docs/frameworks/backend/fastapi</a></li>\n<li>Vercel docs, deploying Next.js <a href=\"https://vercel.com/docs/frameworks/full-stack/nextjs\">https://vercel.com/docs/frameworks/full-stack/nextjs</a></li>\n<li>Vercel docs, deploy button form <a href=\"https://vercel.com/docs/deploy-button\">https://vercel.com/docs/deploy-button</a></li>\n<li>Neon integration example template <a href=\"https://github.com/neondatabase/vercel-marketplace-neon\">https://github.com/neondatabase/vercel-marketplace-neon</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2026-02-22-vercel-deploy-fastapi-nextjs/",
            "title": "Deploying a FastAPI and Next.js website to Vercel",
            "summary": "A straightforward guide on how to deploy a full-stack FastAPI and Next.js app to Vercel, including a ready-to-use code example.\n",
            "date_modified": "2026-02-22T00:00:00.000Z",
            "date_published": "2026-02-22T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2026-02-07-github-login-fastapi-nextjs/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;\nimport GithubLoginArchitectureImage from ’../../../../content/post/2026/02-07-github-login-fastapi-nextjs/_images/github-login-architecture-16-9.png’;\nimport OAuth2FlowDiagramImage from ’../../../../content/post/2026/02-07-github-login-fastapi-nextjs/_images/oauth2-diagram.png’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>In this article, we will show how to implement Github login in a FastAPI and Next.js application. We use Github in this particular case, but the same approach applies to any OAuth provider, you only need to adjust the FastAPI redirect and callback endpoints. Since this is a Next.js app using server components, we will store the session in an HttpOnly cookie. We will dig into implementation details such as domains, cookies, redirects, and overall structuring to achieve a clean, maintainable, and robust solution.</p>\n<h2 id=\"oauth-flow-reminder\">OAuth flow reminder</h2>\n<p><strong>OAuth2 flow sequential diagram:</strong> (<a href=\"https://gist.github.com/cseeman/cf1a0cf7d931794d78f570e9f413f4a1\">Source gist</a>)</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={OAuth2FlowDiagramImage} alt=“OAuth2 flow sequential diagram” /></p>\n<p>Let’s begin with a quick reminder of how OAuth works in very simplified terms. OAuth is built around the principle of a trusted middleman: both we (our app) and the user know who Github is (the authorization server) and trust it. This means we can use Github to identify the user and obtain their information. For the user, this means Github vouches for our app’s identity and legitimacy, clearly showing what information the app will access and at what level, so the user can give informed consent. Of course, there are many more implementation details, but this is enough for a high-level overview.</p>\n<p>For our app, this practically means we need to register it with Github, obtain the app’s client ID and client secret, and then, in the backend, use an OAuth client library to implement two endpoints:</p>\n<ol>\n<li>An endpoint that redirects the user to Github, where they can give consent.</li>\n<li>A callback endpoint where Github redirects the user back to us, passing an authorization code that the auth library can exchange for an access token, which is then used to call Github APIs and obtain additional information about the user.</li>\n</ol>\n<p>Additionally, within the callback endpoint we store the user’s information (email, OAuth ID, name, avatar, etc.) in the database and use the autogenerated database user ID to generate a JWT access token, in the same way we do for a regular email/password authenticated user.</p>\n<p>In this way, we achieve a unified interface for authenticating users, regardless of whether they log in with Github or via email/password.</p>\n<h2 id=\"architecture-overview\">Architecture overview</h2>\n<p><strong>Architecture diagram:</strong></p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={GithubLoginArchitectureImage} alt=“Github login architecture diagram” /></p>\n<p>The one obvious and constant assumption is that we will use a FastAPI backend and a Next.js frontend. When it comes to authorization, this leaves additional room for deciding how we structure the logic and separate concerns. There is more than one way to do this, and some approaches can be fragile and hard to maintain, which is exactly what we want to avoid.</p>\n<p>Let’s go straight to the point and explain the optimal approach that we will use, and then briefly touch on some suboptimal alternatives and the kinds of problems they introduce.</p>\n<ul>\n<li>\n<p>Since we use Next.js and server components, we will store the session in an HttpOnly cookie so we can have private, server-side rendered pages. This means <code>localStorage</code> is not used for storing authentication state.</p>\n</li>\n<li>\n<p>The frontend and backend are separate applications, which means they run as separate Node.js and Python processes and are deployed as separate containers (reminder: Docker containers are meant to run a single process per container). This also applies to the domains on which the frontend and backend run. Ideally, we want complete freedom to use any unrelated domains for both, without making assumptions about subdomains, prefixes, or shared domain structure.</p>\n</li>\n<li>\n<p>Cookies are tightly coupled to domains: a browser will accept and store a cookie only if it is set by a server running on the correct domain. The fact that Next.js provides API functionality is very fortunate, because those endpoints run on the same domain as the rest of the Next.js app (pages) and can set cookies for that exact domain. This removes the need to deal with cross-domain cookies, which are often complex and fragile.</p>\n</li>\n<li>\n<p>Cookies are tied to a domain and are impractical for passing arguments via HTTP responses. Cookies are meant for storing data, not for transmitting it. Consequently, for passing the <code>access_token</code> and <code>expires</code> values, we will use the response body (for server actions) and URL query parameters (for the OAuth redirect). Response bodies and URL query parameters are domain-independent and are designed for passing data between HTTP requests.</p>\n</li>\n<li>\n<p>Separation of concerns on the backend: FastAPI will contain all backend logic, including authorization. This means it will implement both OAuth endpoints (the redirect and callback endpoints). Next.js will handle cookie setting and unsetting logic: via server actions for email/password login, and via Next.js API routes for Github login. As a reminder, a server action is essentially a POST endpoint under the hood and can set or unset cookies.</p>\n</li>\n<li>\n<p>The OAuth callback endpoint in FastAPI needs to initiate an uninterrupted redirect chain composed of two steps (FastAPI and Next.js API): <code>Github -> FastAPI callback redirect -> Next.js API redirect -> Next.js home page</code>. During this process, the <code>access_token</code> and <code>expires</code> values need to be passed as query parameters appended to the URL. Redirects are mandatory because the entire flow is driven by the browser, and we do not want the user’s browser to just land on a raw API response, but rather on the website’s home page as a successfully logged-in user.</p>\n</li>\n</ul>\n<h3 id=\"suboptimal-approaches-and-their-problems\">Suboptimal approaches and their problems</h3>\n<p>There is some ambiguity caused by the redundancy of options, which can lead to suboptimal solutions if we do not think clearly enough. Let’s discuss some of them:</p>\n<ul>\n<li>\n<p>Since we have two backend-capable frameworks, we might be tempted to move a significant part of the authorization logic into Next.js APIs, for example, implementing the OAuth redirect endpoint, the callback endpoint, or even the email/password authentication endpoints there. This would introduce several serious problems, including unnecessary coupling of two backends to the same database and schema, backend deployment being split across two containers that must stay in strict sync, fragmented configuration and secrets management, violation of the “single source of truth” principle, potential read/write race conditions, and increased debugging and logging complexity.</p>\n<p>To prevent all of this, we enforce a clear separation of concerns: FastAPI acts as a complete, standalone backend, while Next.js APIs (and server actions) are responsible only for setting and unsetting cookies. Since Next.js runs on the frontend domain, this approach drastically simplifies and hardens cookie handling.</p>\n</li>\n<li>\n<p>Another pitfall is relying on cross-domain cookies by making assumptions about the domains used by the frontend and backend. For example, we might assume <code>SITE_URL=https://my-website.com</code> for the frontend and <code>API_URL=https://api.my-website.com</code> for the backend. In that case, the backend could tweak cookie properties such as <code>SameSite=None</code> and <code>Domain=.my-website.com</code> to get the browser to accept and store the cookie.</p>\n<p>This introduces additional complexity and fragility into the authentication flow and deployment reliability, along with a number of problems and limitations. Some of them include a major mismatch between email/password login (where the cookie is set directly via a server action) and OAuth login, the inability to host the frontend and backend on completely different, unrelated domains (which is a legitimate requirement), and the inability to host the backend on a PaaS that uses domains included in the public suffix list (<a href=\"https://publicsuffix.org/list/\">https://publicsuffix.org/list/</a>), such as <code>vercel.app</code>.</p>\n<p>Once again, this is solved by letting the Next.js API (and server actions) handle setting and unsetting cookies.</p>\n</li>\n</ul>\n<h2 id=\"implementation\">Implementation</h2>\n<p>That was a lot of text but still no code. On the other hand when we have clear mental model and worked out plan implementation is straight forward.</p>\n<h3 id=\"create-oauth-app-on-github\">Create OAuth app on Github</h3>\n<p>Like with any OAuth provider we need to register our app on Github and obtain client id and client secret. One Github specific is that you can have set only one redirect URL per app, so if you want multiple deployments you will need to create a separate app for each of them.</p>\n<p>It’s a straight forward process, go to your Github profile and open the following menus: <code>Github (top-right avatar) -> Settings -> Developer settings (bottom of the left sidebar) -> OAuth Apps -> New OAuth App</code>. Fill in your app info, including redirect URL where you should set the URL of your FastAPI callback endpoint, e.g. <code>https://api.my-website.com/api/v1/auth/github/callback</code>.</p>\n<p>Then copy <code>Client ID</code> and <code>Client secret</code> and set inside the backend <code>.env</code> file.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">GITHUB_CLIENT_ID</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">Ov23liasdxhfaOJasdf12</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">GITHUB_CLIENT_SECRET</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">c9ad7bc12977515fed61409492abe169212345</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span></code></pre>\n<h3 id=\"instantiate-oauth-client\">Instantiate OAuth client</h3>\n<p>We need to install OAuth client library, we will use <a href=\"https://github.com/authlib/authlib\">authlib/authlib</a>.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Activate venv</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">source</span><span style=\"color:#9ECBFF\"> .venv/bin/activate</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Install authlib</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">poetry</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> authlib</span></span></code></pre>\n<p>Then we can instantiate OAuth client:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#79B8FF\">GITHUB_OAUTH_CONFIG</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"name\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"github\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"client_id\"</span><span style=\"color:#E1E4E8\">: settings.</span><span style=\"color:#79B8FF\">GITHUB_CLIENT_ID</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"client_secret\"</span><span style=\"color:#E1E4E8\">: settings.</span><span style=\"color:#79B8FF\">GITHUB_CLIENT_SECRET</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"access_token_url\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"https://github.com/login/oauth/access_token\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"authorize_url\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"https://github.com/login/oauth/authorize\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"api_base_url\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"https://api.github.com/\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"client_kwargs\"</span><span style=\"color:#E1E4E8\">: {</span><span style=\"color:#9ECBFF\">\"scope\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"user:email\"</span><span style=\"color:#E1E4E8\">},</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> create_oauth</span><span style=\"color:#E1E4E8\">() -> OAuth:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    oauth </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> OAuth()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    oauth.register(</span><span style=\"color:#F97583\">**</span><span style=\"color:#79B8FF\">GITHUB_OAUTH_CONFIG</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> oauth</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">oauth </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> create_oauth()</span></span></code></pre>\n<h3 id=\"define-oauth-endpoints-in-fastapi\">Define OAuth endpoints in FastAPI</h3>\n<p>We can then use the instantiated OAuth client to implement the OAuth redirect and callback endpoints.</p>\n<p>The redirect endpoint is quite simple, almost trivial. When the user hits this endpoint, they are redirected to the Github login page, where they can give consent. The <code>redirect_uri</code> variable contains the absolute URL of our callback endpoint, which we define next.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#B392F0\">@router.get</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">\"/login/github\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">async</span><span style=\"color:#F97583\"> def</span><span style=\"color:#B392F0\"> login_github</span><span style=\"color:#E1E4E8\">(request: Request):</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    Redirect to Github login page</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    Must initiate OAuth flow from backend</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    redirect_uri </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> request.url_for(</span><span style=\"color:#9ECBFF\">\"auth_github_callback\"</span><span style=\"color:#E1E4E8\">)  </span><span style=\"color:#6A737D\"># matches function name</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # rewrite to https in production</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> is_prod:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        redirect_uri </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> redirect_uri.replace(</span><span style=\"color:#FFAB70\">scheme</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"https\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> security.oauth.github.authorize_redirect(request, redirect_uri)</span></span></code></pre>\n<p>Now we can define the callback endpoint, which is where Github sends the user after they have logged in on Github. This part is a bit more complex.</p>\n<p>Github includes an authorization code as a URL parameter, which we use to obtain an OAuth access token. We then use this token to call two separate Github APIs: one to retrieve the user’s profile information (full name, username, and OAuth ID), and another to retrieve the user’s primary email address. Next, we find or create the user in our database. Finally, we use the user’s database ID to create a JWT token, in exactly the same way as we do for a regular email/password user.</p>\n<p>Next, we calculate the <code>expires</code> value for the session cookie so that it matches the JWT <code>access_token</code> expiration. We then attach the <code>access_token</code> and <code>expires</code> values as query parameters to the redirect URL. The redirect URL is constructed as <code>f\"{settings.SITE_URL}/api/auth/set-cookie\"</code>, pointing to a Next.js API endpoint (which we define next) that is responsible for actually setting the cookie. Finally, we redirect the user.</p>\n<p>Once again, it is important to emphasize that the redirect is essential so the browser can follow the entire chain. We do not want the user to land on a raw API response, the home page is the final destination after a successful login.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#B392F0\">@router.get</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">\"/auth/github/callback\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">async</span><span style=\"color:#F97583\"> def</span><span style=\"color:#B392F0\"> auth_github_callback</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    request: Request, session: SessionDep</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">) -> RedirectResponse:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    Github OAuth callback, Github will call this endpoint</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Exchange code for access token</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    token </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> security.oauth.github.authorize_access_token(request)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Get user profile Github API</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    user_info </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> security.oauth.github.get(</span><span style=\"color:#9ECBFF\">\"user\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">token</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">token)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    profile </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> user_info.json()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Get primary email Github API</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    emails </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> security.oauth.github.get(</span><span style=\"color:#9ECBFF\">\"user/emails\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">token</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">token)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    primary_email </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> next</span><span style=\"color:#E1E4E8\">((e[</span><span style=\"color:#9ECBFF\">\"email\"</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> e </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> emails.json() </span><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> e[</span><span style=\"color:#9ECBFF\">\"primary\"</span><span style=\"color:#E1E4E8\">]), </span><span style=\"color:#79B8FF\">None</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    logger.info(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"Primary Github email: </span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">primary_email</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Authenticate or create user</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    user </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> crud.authenticate_github(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        session</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">session,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        primary_email</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">primary_email,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        profile</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">profile,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    )</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires_delta </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> timedelta(</span><span style=\"color:#FFAB70\">hours</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">settings.</span><span style=\"color:#79B8FF\">ACCESS_TOKEN_EXPIRE_HOURS</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    access_token </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> security.create_access_token(user.id, expires_delta)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Absolute expiration timestamp (UTC)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires_at </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> datetime.now(timezone.utc) </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> expires_delta</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires_timestamp </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> int</span><span style=\"color:#E1E4E8\">(expires_at.timestamp())</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Build redirect URL to Next.js cookie-setter</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    base_url </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> f</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">settings.</span><span style=\"color:#79B8FF\">SITE_URL}</span><span style=\"color:#9ECBFF\">/api/auth/set-cookie\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    query </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> urlencode(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            \"access_token\"</span><span style=\"color:#E1E4E8\">: access_token,</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            \"expires\"</span><span style=\"color:#E1E4E8\">: expires_timestamp,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    )</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    redirect_url </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> f</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">base_url</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">?</span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">query</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    response </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> RedirectResponse(</span><span style=\"color:#FFAB70\">url</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">redirect_url, </span><span style=\"color:#FFAB70\">status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">302</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> response</span></span></code></pre>\n<p>Note the fixed dict type <code>Token</code> used for passing cookie properties. It is important that this type is identical and shared between both OAuth and email/password flows, ensuring that they conform to the same interface.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#F97583\">class</span><span style=\"color:#B392F0\"> Token</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">SQLModel</span><span style=\"color:#E1E4E8\">):</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    access_token: </span><span style=\"color:#79B8FF\">str</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Absolute Date, timestamp, sufficient</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires: </span><span style=\"color:#79B8FF\">int</span></span></code></pre>\n<h3 id=\"set-cookie---nextjs-api-endpoint\">Set cookie - Next.js API endpoint</h3>\n<p>Now that we have identified the user on Github and created the JWT <code>access_token</code>, the only remaining step is to set the cookie. As mentioned earlier, in the OAuth flow this is done in a Next.js API endpoint.</p>\n<p>Below is the complete endpoint implementation. As you can see, it is not too complicated. We simply parse the <code>access_token</code> and <code>expires</code> values from the URL query parameters, use them to construct the cookie, and attach the cookie to a redirect response that sends the user to the home page. This final step sets the cookie, and that’s it.</p>\n<p>It’s also worth mentioning that if the query parameters are invalid, we redirect the user back to the login page.</p>\n<p>Note that we construct the cookie as host-only (<code>domain: undefined</code>), meaning it is valid only for the frontend domain. This is perfectly fine and exactly what we want, since in a Next.js app both pages and APIs run on the same domain.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> GET</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">request</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Request</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Response</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> getPublicEnv</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProd</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> url</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> URL</span><span style=\"color:#E1E4E8\">(request.url);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> accessToken</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> url.searchParams.</span><span style=\"color:#B392F0\">get</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'access_token'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> expiresParam</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> url.searchParams.</span><span style=\"color:#B392F0\">get</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'expires'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> hasAllData</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> accessToken </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> expiresParam;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">hasAllData) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> loginUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> URL</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">`${</span><span style=\"color:#79B8FF\">LOGIN</span><span style=\"color:#9ECBFF\">}?error=missing_auth_token`</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> NextResponse.</span><span style=\"color:#B392F0\">redirect</span><span style=\"color:#E1E4E8\">(loginUrl, { status: </span><span style=\"color:#79B8FF\">302</span><span style=\"color:#E1E4E8\"> });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Convert Unix timestamp (seconds) to a JS Date object</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> expiresDate</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">Number</span><span style=\"color:#E1E4E8\">(expiresParam) </span><span style=\"color:#F97583\">*</span><span style=\"color:#79B8FF\"> 1000</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> redirectUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> URL</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">DASHBOARD</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> response</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> NextResponse.</span><span style=\"color:#B392F0\">redirect</span><span style=\"color:#E1E4E8\">(redirectUrl, { status: </span><span style=\"color:#79B8FF\">302</span><span style=\"color:#E1E4E8\"> });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  response.cookies.</span><span style=\"color:#B392F0\">set</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    name: </span><span style=\"color:#79B8FF\">AUTH_COOKIE</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // passed from backend</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    value: accessToken,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires: expiresDate,</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // frontend-specific</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    httpOnly: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    secure: isProd,</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // host-only</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    path: </span><span style=\"color:#9ECBFF\">'/'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    sameSite: </span><span style=\"color:#9ECBFF\">'lax'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    domain: </span><span style=\"color:#79B8FF\">undefined</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> response;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h3 id=\"setunset-cookie---nextjs-server-action\">Set/unset cookie - Next.js server action</h3>\n<p>Although a server action is used to set the session cookie only for the email/password login, it is important to explain the complete authentication picture, show how both login flows conform to the same interface, and highlight some differences.</p>\n<p>In contrast to the OAuth flow, which relies on redirects, the email/password login can simply call the FastAPI endpoint <code>LoginService.loginAccessToken({ body })</code> and obtain the <code>access_token</code> and <code>expires</code> values from the response body to construct the cookie.</p>\n<p>Once again, the cookie is host-only (<code>domain: undefined</code>) and included in the server action response. Under the hood, a server action is just a POST request, which effectively sets the cookie.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> loginAction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  _prevState</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ApiResult</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  formData</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FormData</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">ApiResult</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> getPublicEnv</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> body</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> Object.</span><span style=\"color:#B392F0\">fromEntries</span><span style=\"color:#E1E4E8\">(formData) </span><span style=\"color:#F97583\">as</span><span style=\"color:#B392F0\"> BodyLoginLoginAccessToken</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> apiResponse</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> LoginService.</span><span style=\"color:#B392F0\">loginAccessToken</span><span style=\"color:#E1E4E8\">({ body });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">response</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">_</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#F97583\">...</span><span style=\"color:#79B8FF\">result</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> apiResponse;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isSuccess</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> isSuccessApiResult</span><span style=\"color:#E1E4E8\">(result);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // UI will display backend error</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isSuccess) </span><span style=\"color:#F97583\">return</span><span style=\"color:#E1E4E8\"> result;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">access_token</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">expires</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> result.data;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isProd</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> NODE_ENV</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'production'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Convert Unix timestamp (seconds) to a JS Date object</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> expiresDate</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Date</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">Number</span><span style=\"color:#E1E4E8\">(expires) </span><span style=\"color:#F97583\">*</span><span style=\"color:#79B8FF\"> 1000</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> cookieStore</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> cookies</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  cookieStore.</span><span style=\"color:#B392F0\">set</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    name: </span><span style=\"color:#79B8FF\">AUTH_COOKIE</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // args</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    value: access_token,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires: expiresDate,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // local</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    httpOnly: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    secure: isProd,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // host-only for exact frontend domain</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    path: </span><span style=\"color:#9ECBFF\">'/'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    sameSite: </span><span style=\"color:#9ECBFF\">'lax'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    domain: </span><span style=\"color:#79B8FF\">undefined</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // success result is ignored, just for type</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> result;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<p>Another server action is used for logout. It simply unsets the cookie and applies to both email/password and OAuth logins, since both rely on the same session cookie.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> logoutAction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> ()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">void</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> cookiesList</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> cookies</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  cookiesList.</span><span style=\"color:#B392F0\">delete</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">AUTH_COOKIE</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  redirect</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">LOGIN</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h3 id=\"differences-between-the-emailpassword-and-oauth-flows\">Differences between the email/password and OAuth flows</h3>\n<p>So what is different between the email/password and OAuth flows?</p>\n<p>The OAuth flow is based on two consecutive redirects: <code>FastAPI callback endpoint -> Next.js API set-cookie endpoint -> Home page</code>. This is mandatory because the callback endpoint is the only thing Github provides us, and the <code>access_token</code> and <code>expires</code> values must be passed as query parameters attached to the redirect responses.</p>\n<p>In contrast, the email/password flow is based on an HTML form and a server action, which follows a standard request/response pattern. This allows the <code>access_token</code> and <code>expires</code> values to be sent directly in the response body.</p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Repository:</strong> <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> clone</span><span style=\"color:#9ECBFF\"> git@github.com:nemanjam/full-stack-fastapi-template-nextjs.git</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> 45c840d48cba2aeab07e0a66f8245110b852571e</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Backend</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/api/routes/login.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/core/security.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/crud.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/models.py</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Frontend container (Next.js)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># API route</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/app/api/auth/set-cookie/route.ts</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Server action</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/actions/auth.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Pull request with most of the code shown in a clear diff</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/pull/4</span></span></code></pre>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>Let’s conclude this article by summarizing the upsides and downsides of choosing to move the cookie-setting logic to Next.js server actions and APIs.</p>\n<p><strong>Upsides:</strong></p>\n<ul>\n<li>The frontend and backend are fully independent. We can use any domains for both by simply setting the <code>SITE_URL</code> and <code>API_URL</code> environment variables.</li>\n<li>We can deploy to platforms like <code>vercel.app</code> without needing any additional modifications.</li>\n<li>We maintain a single, unified interface for both email/password and OAuth logins.</li>\n<li>The approach is applicable to any OAuth provider, not just Github. You only need to define the appropriate redirect and callback endpoints in FastAPI.</li>\n</ul>\n<p><strong>Downsides:</strong></p>\n<ul>\n<li>Slightly increased complexity caused by moving the cookie-setting logic into Next.js server actions and APIs.</li>\n<li>The frontend container must include a Node.js runtime to support Next.js server actions and APIs. In practice, this is not much of a downside, since using SSR was already part of the plan.</li>\n</ul>\n<p>Have you implemented something similar yourself? What approach did you choose? Let me know in the comments.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Authlib FastAPI docs <a href=\"https://docs.authlib.org/en/latest/client/fastapi.html\">https://docs.authlib.org/en/latest/client/fastapi.html</a>, blog article <a href=\"https://blog.authlib.org/2020/fastapi-google-login\">https://blog.authlib.org/2020/fastapi-google-login</a>, example <a href=\"https://github.com/authlib/demo-oauth-client/tree/master/fastapi-google-login\">https://github.com/authlib/demo-oauth-client/tree/master/fastapi-google-login</a></li>\n<li>List of all known public suffixes <a href=\"https://publicsuffix.org/list/\">https://publicsuffix.org/list/</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2026-02-07-github-login-fastapi-nextjs/",
            "title": "Github login with FastAPI and Next.js",
            "summary": "A practical example of implementing Github OAuth in FastAPI, and why Next.js server actions and API routes are convenient for managing cookies and domains.\n",
            "date_modified": "2026-02-07T00:00:00.000Z",
            "date_published": "2026-02-07T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2026-01-03-nextjs-server-actions-fastapi-openapi/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;\nimport DeploymentDiagramImage from ’../../../../content/post/2026/01-03-nextjs-server-actions-fastapi-openapi/_images/deployment-diagram-16-8.png’\nimport SequentialDiagramImage from ’../../../../content/post/2026/01-03-nextjs-server-actions-fastapi-openapi/_images/sequential-diagram.png’</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>In 2026, Next.js and server components are the industry standard for building server-side rendered React websites. Next.js comes with API routes by default for building backend endpoints, but for various reasons you may want to use a completely different programming language and a non-TypeScript backend. For example, FastAPI is known for its excellent integration with ML and AI libraries, which are typically implemented in Python.</p>\n<p>Today we will show how to implement one critical part of every full-stack app: data fetching and data mutations using Next.js and FastAPI. There is more than one way to do this, but we will aim to choose and implement the best one.</p>\n<p>We will not start completely from scratch, but instead reuse <a href=\"https://github.com/fastapi/full-stack-fastapi-template\">https://github.com/fastapi/full-stack-fastapi-template</a> as a starting point. It already provides a solid foundation, especially on the backend, where we will only change session storage from <code>localStorage</code> to an HttpOnly cookie. In contrast, we will replace most of the frontend code by switching from TanStack Router, React Query, and Chakra to Next.js 16, ShadcnUI, and Tailwind CSS v4.</p>\n<h2 id=\"the-problem-statement-and-requirements\">The problem statement and requirements</h2>\n<p>What is the real challenge here? Modern React 19 and Next.js 16 provide new, advanced features and a standardized workflow not only for fetching data into components but also for managing state. These span both the server and the client, and we should aim to fully leverage them.</p>\n<p>So the real goal is this: we want to use a non-TypeScript backend while at the same time preserving the well-established server components model for data fetching and the server actions model for data mutations, with the same level of type safety and state management we would have when using Next.js API endpoints.</p>\n<h2 id=\"architecture-overview\">Architecture overview</h2>\n<p>Visual representation of the architecture we will build in this tutorial.</p>\n<p><strong>Deployment diagram:</strong></p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={DeploymentDiagramImage} alt=“Deployment diagram, client JavaScript, Next.js server and FastAPI server” /></p>\n<p><strong>Sequential diagram:</strong></p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={SequentialDiagramImage} alt=“Sequential diagram, client JavaScript, Next.js server and FastAPI server” /></p>\n<h2 id=\"modify-the-backend-to-use-httponly-cookie-auth\">Modify the backend to use HttpOnly cookie auth</h2>\n<p>Server-side rendering and server components are central features of modern Next.js and React. The original starter stores the session in the browser’s <code>localStorage</code>, which prevents us from having server-side rendered private pages and components. We will modify this to store the session in an HttpOnly cookie instead, which we can access and read in server components.</p>\n<p>In the original repository, there is already a pull request, <a href=\"https://github.com/fastapi/full-stack-fastapi-template/pull/1606\">Replace plaintext auth tokens with HttpOnly cookies #1606</a>, that implements this. We will reuse it and adapt it to our needs. Let’s highlight the most important parts of this code and discuss them.</p>\n<p>First, let’s create utilities to set and unset the auth cookie in API responses. Note the signature of the <code>set_auth_cookie</code> method: <code>set_auth_cookie(subject, expires_delta, response)</code>. We pass in the subject (typically a <code>user.id</code>) and the token’s expiration, as well as a <code>response</code> argument of the base class type <code>Response</code> so that this utility can be applied to any specific response subclass. The <code>create_access_token()</code> method itself remains unchanged; the token is created the same way for both <code>localStorage</code> and cookies.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/backend/app/core/security.py\">backend/app/core/security.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> set_auth_cookie</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    subject: </span><span style=\"color:#79B8FF\">str</span><span style=\"color:#F97583\"> |</span><span style=\"color:#E1E4E8\"> Any, expires_delta: timedelta, response: Response</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">) -> Response:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Cookie expiration and JWT expiration match</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Note: cookie expiration must be in seconds</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    expires_in_seconds </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> int</span><span style=\"color:#E1E4E8\">(expires_delta.total_seconds())</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    access_token </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> create_access_token(subject, expires_delta)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Dev defaults</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    samesite </span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\"> \"lax\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    domain </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> None</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Prod overrides</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> is_prod:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        samesite </span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\"> \"none\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Note: important for cross-site cookies in prod to succeed</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # api-site.rpi.example.com and site.rpi.example.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        parsed </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> urlparse(settings.</span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        domain </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> parsed.hostname  </span><span style=\"color:#6A737D\"># full domain</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # if it has subdomains whitelist cookie for \"1 level less\" subdomain, rpi.example.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        host_segments </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> domain.split(</span><span style=\"color:#9ECBFF\">\".\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#79B8FF\"> len</span><span style=\"color:#E1E4E8\">(host_segments) </span><span style=\"color:#F97583\">></span><span style=\"color:#79B8FF\"> 2</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            domain </span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\"> \".\"</span><span style=\"color:#E1E4E8\">.join(host_segments[</span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">:])  </span><span style=\"color:#6A737D\"># remove the first segment (head)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    logger.info(</span><span style=\"color:#F97583\">f</span><span style=\"color:#9ECBFF\">\"domain: </span><span style=\"color:#79B8FF\">{</span><span style=\"color:#E1E4E8\">domain</span><span style=\"color:#79B8FF\">}</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    response.set_cookie(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        key</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">settings.</span><span style=\"color:#79B8FF\">AUTH_COOKIE</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        value</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">access_token,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        httponly</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">True</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        max_age</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">expires_in_seconds,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        expires</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">expires_in_seconds,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        samesite</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">samesite,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        secure</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">is_prod,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        domain</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">domain,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    )</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> response</span></span></code></pre>\n<p>The core idea is straightforward: create a token, assign it to the cookie value, and attach the cookie to the <code>response</code> object.</p>\n<p>There is some added cross-site cookie complexity specific to my use case, which you may not necessarily need to replicate, but let’s explain it for the sake of clarity.</p>\n<h3 id=\"cross-site-cookie-configuration\">Cross site cookie configuration</h3>\n<p>In general, in practice, the frontend and backend often don’t share the same domain, and you need to account for this because cookies are tied to a domain. For example, if the frontend is on <code>my-website.com</code> and the backend is on <code>api.my-website.com</code>, the backend must set <code>domain = \"my-website.com\"</code> and <code>samesite = \"none\"</code> for the browser to accept and store the cookie.</p>\n<p>On my server, I use an additional Traefik TCP router that treats a dot <code>.</code> as a special delimiter character, which prevents it from correctly routing infinite-depth subdomains. As a result, I had to use a dash <code>-</code> instead for my backend domain.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># frontend url</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># backend url</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://api-full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com</span></span></code></pre>\n<p>The additional cookie domain logic essentially does the following: if the frontend is on a subdomain, it sets the cookie for the parent domain (one level up). For example, in this specific case, <code>arm1.nemanjamitic.com</code>.</p>\n<p>That’s enough for this digression, I just wanted to emphasize that you must carefully adjust the <code>domain</code> property of a cross-site cookie depending on the URLs where you host your frontend and backend. Otherwise, the browser will reject the cookie, and authentication will fail.</p>\n<p>Similarly, we use this code to unset the auth cookie. We simply return a <code>JSONResponse</code> containing an expired cookie with the same key. It can be improved, but it will suffice for now.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/backend/app/core/security.py\">backend/app/core/security.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> delete_auth_cookie</span><span style=\"color:#E1E4E8\">() -> JSONResponse:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    response </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> JSONResponse(</span><span style=\"color:#FFAB70\">content</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#9ECBFF\">\"message\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Logout successful\"</span><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    response.delete_cookie(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        key</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">settings.</span><span style=\"color:#79B8FF\">AUTH_COOKIE</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        path</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"/\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        domain</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">None</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        httponly</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">True</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        samesite</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"lax\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        secure</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">is_prod,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    )</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> response</span></span></code></pre>\n<h3 id=\"login--logout-endpoints\">Login / logout endpoints</h3>\n<p>Now it’s time to make use of these utilities to log in and log out a user.</p>\n<p>For login, we first verify that the user has provided a valid email and password. If the credentials are correct, we use the <code>user.id</code> to generate an access token, set it as the cookie value, and include the cookie in the response using the previously mentioned <code>security.set_auth_cookie()</code> utility.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/backend/app/api/routes/login.py\">backend/app/api/routes/login.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#B392F0\">@router.post</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">\"/login/access-token\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> login_access_token</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">) -> JSONResponse:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    OAuth2-compatible token login: get an access token for future requests (sent in an HTTP-only cookie)</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    user </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> crud.authenticate(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">        session</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">session, </span><span style=\"color:#FFAB70\">email</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">form_data.username, </span><span style=\"color:#FFAB70\">password</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">form_data.password</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    )</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> user:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#E1E4E8\"> HTTPException(</span><span style=\"color:#FFAB70\">status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">400</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">detail</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Incorrect email or password\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    elif</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> user.is_active:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#E1E4E8\"> HTTPException(</span><span style=\"color:#FFAB70\">status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">400</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">detail</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Inactive user\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    access_token_expires </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> timedelta(</span><span style=\"color:#FFAB70\">hours</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">settings.</span><span style=\"color:#79B8FF\">ACCESS_TOKEN_EXPIRE_HOURS</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    response </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> JSONResponse(</span><span style=\"color:#FFAB70\">content</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#9ECBFF\">\"message\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"Login successful\"</span><span style=\"color:#E1E4E8\">})</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> security.set_auth_cookie(user.id, access_token_expires, response)</span></span></code></pre>\n<p>For logout, we simply use the <code>security.delete_auth_cookie()</code> utility to unset the cookie from the user’s browser.</p>\n<p>Note: we are implementing this endpoint for the sake of completeness in the FastAPI backend. In our particular setup, however, we will use the Next.js server to clear the auth cookie.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/backend/app/api/routes/login.py\">backend/app/api/routes/login.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#B392F0\">@router.post</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">\"/logout\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">dependencies</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">[Depends(get_current_user)])</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> logout</span><span style=\"color:#E1E4E8\">() -> JSONResponse:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    Delete the HTTP-only cookie during logout</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> security.delete_auth_cookie()</span></span></code></pre>\n<h3 id=\"protect-endpoints-with-auth\">Protect endpoints with auth</h3>\n<p>After implementing login and logout, we can use them to protect specific API endpoints by identifying the user from the request and checking whether they have sufficient privileges to access a resource or perform an action.</p>\n<p>We will use FastAPI’s dependency injection to centralize the logic for obtaining the auth cookie with <code>CookieDep</code> and for identifying the user who sent the cookie with <code>CurrentUser</code>. The <code>get_current_user()</code> method checks for the existence of the cookie, decodes and verifies the validity of the access token from the cookie, and finally uses the <code>user.id</code> from the token’s subject to query the user from the database.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/backend/app/api/deps.py\">backend/app/api/deps.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">cookie_scheme </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> APIKeyCookie(</span><span style=\"color:#FFAB70\">name</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">settings.</span><span style=\"color:#79B8FF\">AUTH_COOKIE</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">CookieDep </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Annotated[</span><span style=\"color:#79B8FF\">str</span><span style=\"color:#E1E4E8\">, Depends(cookie_scheme)]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> get_current_user</span><span style=\"color:#E1E4E8\">(session: SessionDep, cookie: CookieDep) -> User:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> cookie:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#E1E4E8\"> HTTPException(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">            status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">status.</span><span style=\"color:#79B8FF\">HTTP_401_UNAUTHORIZED</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">            detail</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Not authenticated\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        )</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    try</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        payload </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> jwt.decode(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            cookie, settings.</span><span style=\"color:#79B8FF\">JWT_SECRET_KEY</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">algorithms</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">[security.</span><span style=\"color:#79B8FF\">ALGORITHM</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        )</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        token_data </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> TokenPayload(</span><span style=\"color:#F97583\">**</span><span style=\"color:#E1E4E8\">payload)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    except</span><span style=\"color:#E1E4E8\"> (InvalidTokenError, ValidationError):</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#E1E4E8\"> HTTPException(</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">            status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">status.</span><span style=\"color:#79B8FF\">HTTP_403_FORBIDDEN</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">            detail</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Could not validate credentials\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        )</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    user </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> session.get(User, token_data.sub)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> user:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#E1E4E8\"> HTTPException(</span><span style=\"color:#FFAB70\">status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">404</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">detail</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"User not found\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#F97583\"> not</span><span style=\"color:#E1E4E8\"> user.is_active:</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        raise</span><span style=\"color:#E1E4E8\"> HTTPException(</span><span style=\"color:#FFAB70\">status_code</span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\">400</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">detail</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Inactive user\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> user</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">CurrentUser </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Annotated[User, Depends(get_current_user)]</span></span>\n<span class=\"line\"></span></code></pre>\n<p>Now, any function or route in FastAPI can make use of the <code>CurrentUser</code> dependency to identify the user simply by including it as a typed argument. Below is a simple <code>/me</code> endpoint for illustration.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/backend/app/api/routes/users.py\">backend/app/api/routes/users.py</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"py\"><code><span class=\"line\"><span style=\"color:#B392F0\">@router.get</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">\"/me\"</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">response_model</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">UserPublic)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">def</span><span style=\"color:#B392F0\"> read_user_me</span><span style=\"color:#E1E4E8\">(current_user: CurrentUser) -> Any:</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    Get current user.</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    \"\"\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> current_user</span></span></code></pre>\n<p>With this HttpOnly cookie setup, authentication on the backend is complete. Essentially, we didn’t change much, we only moved the access token from the JSON body of the response to the cookie header.</p>\n<h2 id=\"generate-and-configure-openapi-client\">Generate and configure OpenAPI client</h2>\n<p>Now that we have moved the session from <code>localStorage</code> to an HttpOnly cookie, we can identify the user in server components on the Next.js server. An important and obvious note worth repeating: HttpOnly cookies are accessible only on the Next.js server, not in the client-side JavaScript running in the browser.</p>\n<p>Since our setup involves <code>Next.js client -> Next.js server -> FastAPI server</code>, we need to handle cookie transmission accordingly. We will continue using the <code>@hey-api/openapi-ts</code> package from the original template, but for our Next.js app, we will use the <code>@hey-api/client-next</code> client and configure it to handle the auth cookie properly.</p>\n<p>Here is the configuration for generating the OpenAPI client:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/openapi-ts.config.ts\">frontend/apps/web/openapi-ts.config.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> config</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> HeyApiConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> defineConfig</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  input: </span><span style=\"color:#9ECBFF\">'./openapi.json'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  output: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    format: </span><span style=\"color:#9ECBFF\">'prettier'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    lint: </span><span style=\"color:#9ECBFF\">'eslint'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    path: </span><span style=\"color:#9ECBFF\">'./src/client'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    importFileExtension: </span><span style=\"color:#79B8FF\">null</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  exportSchemas: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#6A737D\">// backend models types</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  plugins: [</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Note: order matters</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'@hey-api/typescript'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      enums: </span><span style=\"color:#9ECBFF\">'javascript'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#6A737D\">// const objects instead of enums</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    },</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    '@hey-api/schemas'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#6A737D\">// default json, req.body, '{\"username\":\"abc\",\"password\":\"123\"}'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'@hey-api/sdk'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      asClass: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#6A737D\">// UsersService.readUserMe(), 'true' doesn't allow tree-shaking</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      classNameBuilder: </span><span style=\"color:#9ECBFF\">'{{name}}Service'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#6A737D\">// class Users -> UsersService</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // @ts-expect-error @hey-api/openapi-ts doesn't export types</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      methodNameBuilder, </span><span style=\"color:#6A737D\">// usersReadUserMe -> readUserMe</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: </span><span style=\"color:#9ECBFF\">'@hey-api/client-next'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // relative from src/client/ folder</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      runtimeConfigPath: </span><span style=\"color:#9ECBFF\">'../lib/hey-api'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#6A737D\">// sets API_URL, auth...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  ],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">});</span></span></code></pre>\n<p>The important parts are: we choose the Next.js client <code>@hey-api/client-next</code>, control the SDK method names using the <code>methodNameBuilder</code> callback, and specify the path for the generated client runtime configuration with <code>runtimeConfigPath</code>.</p>\n<h3 id=\"runtime-client-configuration\">Runtime client configuration</h3>\n<p>There are several ways to set runtime configuration for the generated client. We will use the <code>runtimeConfigPath</code> field to specify the path to the configuration file, as this is the recommended approach according to the docs: <a href=\"https://heyapi.dev/openapi-ts/clients/next-js#runtime-api\">https://heyapi.dev/openapi-ts/clients/next-js#runtime-api</a>.</p>\n<p>The auth cookie is originally stored in the browser and is included in client requests by default. An important note is that the cookie in these requests is valid only for the frontend domain (Next.js server) and not for the backend domain. If you try calling the FastAPI backend using this cookie, you will get a <code>403 Forbidden</code> HTTP error. That’s why requests from client code need to be proxied through a Next.js API endpoint to the FastAPI backend.</p>\n<p>In contrast, HTTP requests made from the Next.js server do not include the cookie automatically at all, so we need to forward it explicitly.</p>\n<p>We handle both of these requirements in a base runtime configuration at the SDK instance level in a single place, so we don’t have to repeat this logic for every specific call.</p>\n<p>Here is the code:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/lib/hey-api.ts\">frontend/apps/web/src/lib/hey-api.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">CLIENT_PROXY</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> ROUTES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">API</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/** Runtime config. Runs and imported both on server and in browser. */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> createClientConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> CreateClientConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">config</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> getPublicEnv</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    ...</span><span style=\"color:#E1E4E8\">config,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    baseUrl: </span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    credentials: </span><span style=\"color:#9ECBFF\">'include'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    fetch: </span><span style=\"color:#B392F0\">isServer</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> serverFetch </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> clientFetch,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> serverFetch</span><span style=\"color:#F97583\">:</span><span style=\"color:#F97583\"> typeof</span><span style=\"color:#B392F0\"> fetch</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">input</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">init</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {}) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Note: Dynamic import to avoid bundling 'next/headers' on client</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">cookies</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#F97583\"> import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'next/headers'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> cookieStore</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> cookies</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> cookieHeader</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> cookieStore</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">getAll</span><span style=\"color:#E1E4E8\">()</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">c</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#9ECBFF\"> `${</span><span style=\"color:#E1E4E8\">c</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">name</span><span style=\"color:#9ECBFF\">}=${</span><span style=\"color:#E1E4E8\">c</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">value</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">join</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'; '</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Note: must append auth_cookie like this or content-type header will break in server actions</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> headers</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Headers</span><span style=\"color:#E1E4E8\">(init.headers);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  headers.</span><span style=\"color:#B392F0\">append</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Cookie'</span><span style=\"color:#E1E4E8\">, cookieHeader);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // test skeletons styling</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // await waitMs(3000);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> response</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> fetch</span><span style=\"color:#E1E4E8\">(input, { </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">init, headers });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> response;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/** Client-side fetch: forwards requests to api/client-proxy/[...path]/route.ts */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> clientFetch</span><span style=\"color:#F97583\">:</span><span style=\"color:#F97583\"> typeof</span><span style=\"color:#B392F0\"> fetch</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">input</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">init</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {}) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> getPublicEnv</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">as</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">API_URL</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\"> };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // hey-api sends absolute URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> url</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> typeof</span><span style=\"color:#E1E4E8\"> input </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> 'string'</span><span style=\"color:#F97583\"> ?</span><span style=\"color:#E1E4E8\"> input </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> input.</span><span style=\"color:#B392F0\">toString</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Normalize to relative URL</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // API_URL.length + 1 - removes leading slash, API_URL guaranteed not to have trailing slash</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> relativeUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> url.</span><span style=\"color:#B392F0\">startsWith</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> url.</span><span style=\"color:#B392F0\">slice</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#F97583\"> +</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> url;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Build the proxy URL relative to Next.js API</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> proxyUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> `${</span><span style=\"color:#79B8FF\">CLIENT_PROXY</span><span style=\"color:#9ECBFF\">}${</span><span style=\"color:#E1E4E8\">relativeUrl</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> headers</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Headers</span><span style=\"color:#E1E4E8\">(init.headers);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#B392F0\"> fetch</span><span style=\"color:#E1E4E8\">(proxyUrl, { </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">init, headers, credentials: </span><span style=\"color:#9ECBFF\">'include'</span><span style=\"color:#E1E4E8\"> });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span></code></pre>\n<h3 id=\"server-fetch-override\">Server fetch override</h3>\n<p>The important part here is the <code>serverFetch</code> override for the <code>fetch</code> function used by the generated client on the Next.js server. The <code>serverFetch</code> code typically runs either on a server-side rendered page or in a server action. Both of these have access to the request object (including headers and cookies) that originally comes from the browser.</p>\n<p>Based on this, we read the cookies (including the auth cookie) and forward them to the <code>fetch</code> client, which makes requests from the Next.js server to the FastAPI endpoints. This creates the following chain: <code>browser -> Next.js server -> FastAPI server</code>.</p>\n<p>There are a few additional tricks required to make this work correctly.</p>\n<ul>\n<li>We use a dynamic import, <code>const { cookies } = await import('next/headers')</code>, to prevent bundling server-only code into the client bundle, which would break the build.</li>\n<li>We must create a <code>new Headers()</code> instance and use the <code>append()</code> method to pass cookies to <code>fetch</code>.</li>\n<li>We obtain the <code>API_URL</code> environment variable using <code>const { API_URL } = getPublicEnv()</code> from the <code>next-public-env</code> package. For a reusable build, <code>API_URL</code> must be a runtime variable. Since this code runs both on the server and in the client, it must get the correct value in both environments. I wrote about reusable Next.js builds and runtime environment variables in detail in this article: <a href=\"https://nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables\">Runtime environment variables in Next.js - build reusable Docker images</a>.</li>\n</ul>\n<h3 id=\"example-client-calls-from-nextjs-server-code\">Example client calls from Next.js server code</h3>\n<p>With this OpenAPI client, we can make HTTP authenticated calls from Next.js pages, server components, and server actions to protected FastAPI endpoints.</p>\n<p><strong>Example 1.</strong> Query in a page:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/app/dashboard/items/%5B%5B...page%5D%5D/page.tsx\">frontend/apps/web/src/app/dashboard/items/[[…page]]/page.tsx</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> ItemsPage</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#FFAB70\">params</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">page</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> params;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">currentPage</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">isValidPage</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> parsePage</span><span style=\"color:#E1E4E8\">(page);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isValidPage) </span><span style=\"color:#B392F0\">notFound</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> result</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> ItemsService.</span><span style=\"color:#B392F0\">readItems</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    query: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      skip: (currentPage </span><span style=\"color:#F97583\">-</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">*</span><span style=\"color:#79B8FF\"> PAGE_SIZE_TABLE</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      limit: </span><span style=\"color:#79B8FF\">PAGE_SIZE_TABLE</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> items</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> result.data;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p><strong>Example 2.</strong> Query in a server component:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/components/dashboard/home/list-recent-items.tsx\">frontend/apps/web/src/components/dashboard/home/list-recent-items.tsx</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> ListRecentItems</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> result</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> ItemsService.</span><span style=\"color:#B392F0\">readItems</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  throwIfApiError</span><span style=\"color:#E1E4E8\">(result);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> items</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> result.data?.data </span><span style=\"color:#F97583\">??</span><span style=\"color:#E1E4E8\"> [];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p><strong>Example 3.</strong> Mutation in a server action:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/actions/item.ts\">frontend/apps/web/src/actions/item.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> itemCreateAction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  _prevState</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ApiResult</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  formData</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FormData</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">ApiResult</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> body</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> Object.</span><span style=\"color:#B392F0\">fromEntries</span><span style=\"color:#E1E4E8\">(formData) </span><span style=\"color:#F97583\">as</span><span style=\"color:#B392F0\"> ItemCreate</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> apiResponse</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> ItemsService.</span><span style=\"color:#B392F0\">createItem</span><span style=\"color:#E1E4E8\">({ body });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">response</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">_</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#F97583\">...</span><span style=\"color:#79B8FF\">result</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> apiResponse;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  revalidatePath</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">ITEMS</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> result;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h3 id=\"client-fetch-override\">Client fetch override</h3>\n<p>The <code>clientFetch</code> implementation rewrites the original URL pointing to FastAPI to a Next.js API endpoint that serves as a proxy. With this, every call from the client code is routed to the Next.js API proxy endpoint.</p>\n<p>The Next.js API endpoint then parses the authorization cookie and attaches it to the actual request to the corresponding FastAPI endpoint. It does the same for the response by forwarding the cookie from the FastAPI response back to the Next.js client code. In practice, it acts as a proxy middleman that attaches the cookie in both directions.</p>\n<p>As a result, HTTP requests from the client code can be authenticated correctly with FastAPI instead of being rejected with a <code>403 Forbidden</code> error.</p>\n<p><strong>Note:</strong> This call is not displayed in the architecture diagram, it needs to be updated.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/app/api/client-proxy/%5B...path%5D/route.ts\">frontend/apps/web/src/app/api/client-proxy/[…path]/route.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> proxyHandler</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">request</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> NextRequest</span><span style=\"color:#E1E4E8\">, { </span><span style=\"color:#FFAB70\">params</span><span style=\"color:#E1E4E8\"> }</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ClientProxyRouteParam</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">path</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> params;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> getPublicEnv</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // params.path just splits into array, this just joins back to string</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // path = ['api', 'v1', 'users', 'me'] -> http://api.localhost:8000/api/v1/users/me</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> backendUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> `${</span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#9ECBFF\">}/${</span><span style=\"color:#E1E4E8\">path</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#B392F0\">join</span><span style=\"color:#9ECBFF\">(</span><span style=\"color:#9ECBFF\">'/'</span><span style=\"color:#9ECBFF\">)</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // clone headers from client request</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> headers</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Record</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">string</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">string</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> {};</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  request.headers.</span><span style=\"color:#B392F0\">forEach</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">value</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">key</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (headers[key] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> value));</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">method</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> request;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> body</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'GET'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'HEAD'</span><span style=\"color:#E1E4E8\">].</span><span style=\"color:#B392F0\">includes</span><span style=\"color:#E1E4E8\">(method) </span><span style=\"color:#F97583\">?</span><span style=\"color:#79B8FF\"> undefined</span><span style=\"color:#F97583\"> :</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> request.</span><span style=\"color:#B392F0\">arrayBuffer</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> apiResponse</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> fetch</span><span style=\"color:#E1E4E8\">(backendUrl, { method, headers, body });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> data</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> apiResponse.</span><span style=\"color:#B392F0\">arrayBuffer</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> response</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> NextResponse</span><span style=\"color:#E1E4E8\">(Buffer.</span><span style=\"color:#B392F0\">from</span><span style=\"color:#E1E4E8\">(data), { status: apiResponse.status });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // copy headers from backend response, excluding ones automatically controlled by Node/Next.js internally</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> excludeHeaders</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'content-encoding'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'transfer-encoding'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'connection'</span><span style=\"color:#E1E4E8\">];</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  apiResponse.headers.</span><span style=\"color:#B392F0\">forEach</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    (</span><span style=\"color:#FFAB70\">value</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">key</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">excludeHeaders.</span><span style=\"color:#B392F0\">includes</span><span style=\"color:#E1E4E8\">(key) </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> response.headers.</span><span style=\"color:#B392F0\">set</span><span style=\"color:#E1E4E8\">(key, value)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // optional: return error if response not ok</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">apiResponse.ok) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    console.</span><span style=\"color:#B392F0\">warn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Client proxy returned error:'</span><span style=\"color:#E1E4E8\">, apiResponse.status, backendUrl);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> response;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// add PUT, DELETE, PATCH if needed</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> GET</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> proxyHandler;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> POST</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> proxyHandler;</span></span></code></pre>\n<h3 id=\"example-client-calls-from-nextjs-client-code\">Example client calls from Next.js client code</h3>\n<p><strong>Example 1.</strong> Query in a page’s client component:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/app/page.tsx\">frontend/apps/web/src/app/page.tsx</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">'use client'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">LOGIN</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">DASHBOARD</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> ROUTES</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">HOME_PAGE_REDIRECT</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> DELAY</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Must be client component to show loader before redirect</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> HomePage</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> router</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useRouter</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#B392F0\"> redirect</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // client side call, cookie valid only for Next.js API domain, and not for FastAPI domain</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      const</span><span style=\"color:#79B8FF\"> result</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> UsersService.</span><span style=\"color:#B392F0\">readUserMe</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      const</span><span style=\"color:#79B8FF\"> currentUser</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> result.data;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">      await</span><span style=\"color:#B392F0\"> waitMs</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">HOME_PAGE_REDIRECT</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">      const</span><span style=\"color:#79B8FF\"> redirectUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> currentUser </span><span style=\"color:#F97583\">?</span><span style=\"color:#79B8FF\"> DASHBOARD</span><span style=\"color:#F97583\"> :</span><span style=\"color:#79B8FF\"> LOGIN</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      router.</span><span style=\"color:#B392F0\">push</span><span style=\"color:#E1E4E8\">(redirectUrl);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    redirect</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }, [router]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    &#x3C;</span><span style=\"color:#E1E4E8\">div className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex items-center justify-center min-h-screen\"</span><span style=\"color:#F97583\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      &#x3C;</span><span style=\"color:#E1E4E8\">div className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"text-center\"</span><span style=\"color:#F97583\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        &#x3C;</span><span style=\"color:#E1E4E8\">div className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto\"</span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#B392F0\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        &#x3C;</span><span style=\"color:#E1E4E8\">p className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mt-2 text-gray-600\"</span><span style=\"color:#F97583\">></span><span style=\"color:#E1E4E8\">Redirecting</span><span style=\"color:#F97583\">...&#x3C;/</span><span style=\"color:#E1E4E8\">p</span><span style=\"color:#F97583\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      &#x3C;/</span><span style=\"color:#E1E4E8\">div</span><span style=\"color:#F97583\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    &#x3C;/</span><span style=\"color:#E1E4E8\">div</span><span style=\"color:#F97583\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h2 id=\"mutations\">Mutations</h2>\n<p>The original idea of this article is to preserve the default React workflow, even when using a non-TypeScript backend. To achieve this, the client code must not be aware of the FastAPI endpoints and should never call them directly, but only through the Next.js server, which will forward the requests.</p>\n<p>We have already implemented passing the cookie from the browser to FastAPI via the Next.js server, but we have not yet implemented the opposite direction. The FastAPI login endpoint sets the cookie in the response; now we need to forward it through the Next.js server to the user’s browser. Here is the code that accomplishes this:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/utils/actions.ts\">frontend/apps/web/src/utils/actions.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> forwardCookiesFromResponse</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">response</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Response</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">void</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> rawCookies</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> response.headers.</span><span style=\"color:#B392F0\">get</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'set-cookie'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">rawCookies) </span><span style=\"color:#F97583\">return</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> parsed</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> setCookieParser.</span><span style=\"color:#B392F0\">parse</span><span style=\"color:#E1E4E8\">(rawCookies);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> cookieStore</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> cookies</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> c</span><span style=\"color:#F97583\"> of</span><span style=\"color:#E1E4E8\"> parsed) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    cookieStore.</span><span style=\"color:#B392F0\">set</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      name: c.name,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      value: c.value,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      httpOnly: c.httpOnly,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      secure: c.secure,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      path: c.path </span><span style=\"color:#F97583\">??</span><span style=\"color:#9ECBFF\"> '/'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      sameSite: c.sameSite </span><span style=\"color:#F97583\">as</span><span style=\"color:#79B8FF\"> any</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      expires: c.expires,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<p>The utility method above retrieves the cookies from the FastAPI response object, parses them using the <code>set-cookie-parser</code> package, and finally attaches the parsed cookies to the Next.js response that will be returned as part of the server action.</p>\n<p>We then call this utility method within the login server action, which sets the forwarded cookies in the user’s browser. Under the hood, a server action is just a POST request, and it can set cookies just like any other HTTP call.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/actions/auth.ts\">frontend/apps/web/src/actions/auth.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> loginAction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  _prevState</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ApiResult</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  formData</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FormData</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">ApiResult</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> body</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> Object.</span><span style=\"color:#B392F0\">fromEntries</span><span style=\"color:#E1E4E8\">(formData) </span><span style=\"color:#F97583\">as</span><span style=\"color:#B392F0\"> BodyLoginLoginAccessToken</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> apiResponse</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> LoginService.</span><span style=\"color:#B392F0\">loginAccessToken</span><span style=\"color:#E1E4E8\">({ body });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">response</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#F97583\">...</span><span style=\"color:#79B8FF\">result</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> apiResponse;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  await</span><span style=\"color:#B392F0\"> forwardCookiesFromResponse</span><span style=\"color:#E1E4E8\">(response);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> result;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h2 id=\"react-hook-form-and-useactionstate\">React Hook Form and useActionState</h2>\n<p>Now that we have configured the backend and HTTP client, it is time to handle form submission within our client code. For some time, <code>react-hook-form</code> has been the dominant forms package in the React ecosystem. There are a few tricks to integrate it properly with the <code>useActionState</code> React API and server actions.</p>\n<p>Here is example code for creating an item:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/components/dashboard/items/form-item-create.tsx\">frontend/apps/web/src/components/dashboard/items/form-item-create.tsx</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> defaultValues</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ItemCreateFormValues</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  title: </span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  description: </span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">} </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> resolver</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> zodResolver</span><span style=\"color:#E1E4E8\">(itemCreateSchema);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> FormItemCreate</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#FFAB70\">onSuccess</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">onCancel</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> initialState</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> { data: </span><span style=\"color:#79B8FF\">undefined</span><span style=\"color:#E1E4E8\"> };</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">state</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">formAction</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">isPending</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useActionState</span><span style=\"color:#E1E4E8\">(itemCreateAction, initialState);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> form</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useForm</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">ItemCreateFormValues</span><span style=\"color:#E1E4E8\">>({ resolver, defaultValues });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isSuccess</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> isSuccessApiResult</span><span style=\"color:#E1E4E8\">(state);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (isSuccess) </span><span style=\"color:#B392F0\">onSuccess</span><span style=\"color:#E1E4E8\">?.();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }, [isSuccess, onSuccess]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isError</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> isErrorApiResult</span><span style=\"color:#E1E4E8\">(state);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#B392F0\"> validateAndSubmit</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">event</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FormEvent</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">HTMLFormElement</span><span style=\"color:#E1E4E8\">>) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    event.</span><span style=\"color:#B392F0\">preventDefault</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    form.</span><span style=\"color:#B392F0\">handleSubmit</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      const</span><span style=\"color:#79B8FF\"> formElement</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> event.target </span><span style=\"color:#F97583\">as</span><span style=\"color:#B392F0\"> HTMLFormElement</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      const</span><span style=\"color:#79B8FF\"> formData</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> FormData</span><span style=\"color:#E1E4E8\">(formElement);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      startTransition</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">        formAction</span><span style=\"color:#E1E4E8\">(formData);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        form.</span><span style=\"color:#B392F0\">reset</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    })(event);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#79B8FF\">Form</span><span style=\"color:#E1E4E8\"> {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">form}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">form</span><span style=\"color:#B392F0\"> action</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{formAction} </span><span style=\"color:#B392F0\">onSubmit</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{validateAndSubmit} </span><span style=\"color:#B392F0\">className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"space-y-6\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#79B8FF\">FormField</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          control</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{form.control}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          name</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"title\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          render</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{({ </span><span style=\"color:#FFAB70\">field</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            &#x3C;</span><span style=\"color:#79B8FF\">FormItem</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;</span><span style=\"color:#79B8FF\">FormLabel</span><span style=\"color:#E1E4E8\">>Title *&#x3C;/</span><span style=\"color:#79B8FF\">FormLabel</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;</span><span style=\"color:#79B8FF\">FormControl</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                &#x3C;</span><span style=\"color:#79B8FF\">Input</span><span style=\"color:#E1E4E8\"> {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">field} </span><span style=\"color:#B392F0\">placeholder</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Enter item title...\"</span><span style=\"color:#B392F0\"> disabled</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{isPending} /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;/</span><span style=\"color:#79B8FF\">FormControl</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;</span><span style=\"color:#79B8FF\">FormMessage</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            &#x3C;/</span><span style=\"color:#79B8FF\">FormItem</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#79B8FF\">FormField</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          control</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{form.control}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          name</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"description\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          render</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{({ </span><span style=\"color:#FFAB70\">field</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            &#x3C;</span><span style=\"color:#79B8FF\">FormItem</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;</span><span style=\"color:#79B8FF\">FormLabel</span><span style=\"color:#E1E4E8\">>Description&#x3C;/</span><span style=\"color:#79B8FF\">FormLabel</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;</span><span style=\"color:#79B8FF\">FormControl</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                &#x3C;</span><span style=\"color:#79B8FF\">Input</span><span style=\"color:#E1E4E8\"> {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">field} </span><span style=\"color:#B392F0\">placeholder</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"Enter item description...\"</span><span style=\"color:#B392F0\"> disabled</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{isPending} /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;/</span><span style=\"color:#79B8FF\">FormControl</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;</span><span style=\"color:#79B8FF\">FormMessage</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            &#x3C;/</span><span style=\"color:#79B8FF\">FormItem</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {isError </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#79B8FF\">Alert</span><span style=\"color:#B392F0\"> variant</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"destructive\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            &#x3C;</span><span style=\"color:#79B8FF\">AlertDescription</span><span style=\"color:#E1E4E8\">>{</span><span style=\"color:#B392F0\">getApiErrorMessage</span><span style=\"color:#E1E4E8\">(state.error)}&#x3C;/</span><span style=\"color:#79B8FF\">AlertDescription</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;/</span><span style=\"color:#79B8FF\">Alert</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        )}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"flex justify-end space-x-2\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#79B8FF\">Button</span><span style=\"color:#B392F0\"> type</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"button\"</span><span style=\"color:#B392F0\"> variant</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"outline\"</span><span style=\"color:#B392F0\"> onClick</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{onCancel} </span><span style=\"color:#B392F0\">disabled</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{isPending}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            Cancel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;/</span><span style=\"color:#79B8FF\">Button</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#79B8FF\">Button</span><span style=\"color:#B392F0\"> type</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"submit\"</span><span style=\"color:#B392F0\"> disabled</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{isPending}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            {isPending </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                &#x3C;</span><span style=\"color:#79B8FF\">Loader2</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mr-2 h-4 w-4 animate-spin\"</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                Creating...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              &#x3C;/></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            ) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">              'Create Item'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;/</span><span style=\"color:#79B8FF\">Button</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">form</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#79B8FF\">Form</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h3 id=\"form-submission-and-validation\">Form submission and validation</h3>\n<p>The code above addresses two important requirements:</p>\n<ol>\n<li>\n<p>Server actions are designed to support form submission even with JavaScript disabled. In our code, we support this by assigning the <code>action</code> attribute on the <code>&#x3C;form /></code> tag to the <code>formAction</code> result returned by the <code>useFormAction</code> hook: <code>&#x3C;form action={formAction} ... /></code>.</p>\n</li>\n<li>\n<p>We use client-side JavaScript to validate the fields with Zod and display user-friendly error messages. For this, as well as for form submission when JavaScript is enabled, we use the <code>validateAndSubmit</code> event handler attached to the <code>onSubmit</code> event: <code>&#x3C;form onSubmit={validateAndSubmit} ... /></code>.</p>\n</li>\n</ol>\n<p>I learned this trick from the tutorial linked in this GitHub discussion comment: <a href=\"https://github.com/orgs/react-hook-form/discussions/11832#discussioncomment-11832211\">react-hook-form/discussions/11832#discussioncomment-11832211</a>.</p>\n<h3 id=\"useactionstate\">useActionState</h3>\n<p>React provides the <code>useActionState</code> hook not only to handle form submission and call server actions, but also to manage state, all at the same time. It accepts the server action <code>itemCreateAction</code> as an argument and returns the <code>formAction</code> function, which can be used to bind to the form’s <code>action</code> attribute, invoked manually with an event handler, or both at the same time, as we do in this case.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">state</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">formAction</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">isPending</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useActionState</span><span style=\"color:#E1E4E8\">(itemCreateAction, initialState);</span></span></code></pre>\n<p>State management is modeled in a way that is somewhat reminiscent of reducers in Redux. It uses the concept of previous and next state, where the next state is the result of applying a transformation to the previous state, in this case, the transformation occurs on the server. Naturally, it all starts with an initial state.</p>\n<p>In our example, <code>useActionState</code> accepts <code>initialState</code> as the second argument, and the server action response is contained within the <code>state</code> item in the returned tuple.</p>\n<h2 id=\"server-actions-handle-success-and-error-results\">Server actions handle success and error results</h2>\n<p>There are certain rules that apply to React server actions in general:</p>\n<ol>\n<li>\n<p>In server actions, you shouldn’t throw exceptions but instead return errors within the result. This affects which particular generic we will use to type the OpenAPI client response, because in our case a server action is just a proxy to the respective FastAPI endpoint.</p>\n<p>Here is the <code>ItemsService.createItem()</code> return type as an example. This type is reused for both FastAPI and the server action. This is intentional because the server action is just a proxy, and we want to avoid any unnecessary transformation of the results.</p>\n<p>As mentioned earlier, errors should be included as return values. This type clearly shows that: the result is a union of success and error branches and additionally contains the raw HTTP <code>Response</code> object. You may recall that we already used it to extract the cookie in the login endpoint.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#79B8FF\">Promise</span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    data: ItemPublic;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    error: </span><span style=\"color:#79B8FF\">undefined</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">} </span><span style=\"color:#F97583\">|</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    data: </span><span style=\"color:#79B8FF\">undefined</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    error: HttpValidationError;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}) </span><span style=\"color:#F97583\">&#x26;</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    response</span><span style=\"color:#E1E4E8\">: Response;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span><span style=\"color:#F97583\">></span></span></code></pre>\n</li>\n<li>\n<p>Server actions have limited serialization capabilities. You cannot return class instances, error instances, database model instances, etc. You should mostly rely on object literals for return values from server actions.</p>\n<p>Let’s see the code example:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/actions/item.ts\">frontend/apps/web/src/actions/item.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> itemCreateAction</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  _prevState</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ApiResult</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  formData</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FormData</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">ApiResult</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> body</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> Object.</span><span style=\"color:#B392F0\">fromEntries</span><span style=\"color:#E1E4E8\">(formData) </span><span style=\"color:#F97583\">as</span><span style=\"color:#B392F0\"> ItemCreate</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> apiResponse</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#E1E4E8\"> ItemsService.</span><span style=\"color:#B392F0\">createItem</span><span style=\"color:#E1E4E8\">({ body });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#FFAB70\">response</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">_</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#F97583\">...</span><span style=\"color:#79B8FF\">result</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> apiResponse;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  revalidatePath</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">ITEMS</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> result;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<p>Note that the <code>response</code> field is a class instance and is not serializable, which is why we omit it from the server action return value.</p>\n<p>You may recall that we bound the <code>action</code> attribute to <code>formAction</code> in the form: <code>&#x3C;form action={formAction} ... ></code>. Because of this, form values are received as <code>FormData</code> in the server action, and we use <code>Object.fromEntries()</code> to convert it to a plain object that can be forwarded as the HTTP request body to the FastAPI endpoint.</p>\n</li>\n</ol>\n<h2 id=\"useactionstate-vs-usetransition-to-call-actions\">useActionState vs useTransition to call actions</h2>\n<p>Just a quick reminder: <code>useActionState</code> is not the only way to call a server action. It is typically used with a form element, while also providing state and an <code>isPending</code> flag. We have already seen a code example for this.</p>\n<p>When we have a simple <code>void</code> server action, we can skip the form and simply invoke the action within an event handler. Such calls are typically marked as lower priority by wrapping them with <code>startTransition</code>. Below is a code example:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/components/dashboard/items/dropdown-item.tsx\">frontend/apps/web/src/components/dashboard/items/dropdown-item.tsx</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> DropdownItem</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#FFAB70\">item</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">isPending</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">startTransition</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useTransition</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#B392F0\"> handleDeleteItem</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">userId</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    startTransition</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      itemDeleteAction</span><span style=\"color:#E1E4E8\">(userId);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Repository:</strong> <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> clone</span><span style=\"color:#9ECBFF\"> git@github.com:nemanjam/full-stack-fastapi-template-nextjs.git</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> be2b94b72b343563d21aeac29743099af8512f62</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Backend</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/core/security.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/api/deps.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/api/routes/login.py</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">backend/app/api/routes/users.py</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Frontend</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># OpenAPI configuration</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/openapi-ts.config.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/lib/hey-api.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Queries - Next.js server</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/app/dashboard/items/[[...page]]/page.tsx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/components/dashboard/home/list-recent-items.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Queries - Next.js client</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/app/page.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Next.js API proxy endpoint</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/app/api/client-proxy/[...path]/route.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Mutations</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Server actions</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/utils/actions.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/actions/item.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/actions/auth.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Forms</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/components/dashboard/items/form-item-create.tsx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/components/dashboard/items/dropdown-item.tsx</span></span></code></pre>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>Congratulations on reading this far. It was long to read, but also long to write and figure out.</p>\n<p>Next.js is a comprehensive full-stack meta-framework, but there are situations where you might want to use it with a backend written in a completely different programming language. In this tutorial, we have demonstrated that it is entirely possible to bridge the modern Next.js ecosystem with a powerful Python-based FastAPI backend while preserving the full benefits of React Server Components and Server Actions. This approach shows that you don’t have to compromise on developer experience or type safety when using a non-TypeScript backend.</p>\n<p>The key idea is that <strong>Next.js remains the only thing the browser ever talks to</strong>. FastAPI is treated as <strong>an internal service behind the Next.js server</strong>. This keeps a clean React mental model: components call server actions, server actions call strongly typed OpenAPI clients, and only the Next.js server handles cookies, authentication, and cross-service communication. From the client’s perspective, nothing changes.</p>\n<p>The result is a stack where FastAPI does what it does best (Python, validation, data, ML, background jobs) and Next.js does what it does best (React, rendering, routing, and UX), without either one leaking into the other.</p>\n<p>Feel free to explore the complete implementation in the accompanying repository. It serves as a solid starting point for anyone building AI-powered or data-intensive applications that require the rich AI/ML ecosystem and library availability of Python on the backend, along with the developer experience of modern React on the front.</p>\n<p>Have you faced similar challenges and used a different approach? Feel free to share in the comments. I would love to hear about your experience.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Server Actions, Next.js docs <a href=\"https://nextjs.org/docs/app/getting-started/updating-data\">https://nextjs.org/docs/app/getting-started/updating-data</a></li>\n<li>HttpOnly cookie PR <a href=\"https://github.com/fastapi/full-stack-fastapi-template/pull/1606\">https://github.com/fastapi/full-stack-fastapi-template/pull/1606</a></li>\n<li>HttpOnly cookie branch <a href=\"https://github.com/sinkozs/full-stack-fastapi-template/tree/use-http-only-cookie\">https://github.com/sinkozs/full-stack-fastapi-template/tree/use-http-only-cookie</a></li>\n<li><code>@hey-api/openapi-ts</code> configuration docs <a href=\"https://heyapi.dev/openapi-ts/configuration\">https://heyapi.dev/openapi-ts/configuration</a></li>\n<li><code>@hey-api/client-next</code> client docs <a href=\"https://heyapi.dev/openapi-ts/clients/next-js\">https://heyapi.dev/openapi-ts/clients/next-js</a></li>\n<li>Connect React Hook Form with server action trick, blog article <a href=\"https://github.com/orgs/react-hook-form/discussions/11832#discussioncomment-11832211\">https://github.com/orgs/react-hook-form/discussions/11832#discussioncomment-11832211</a>, <a href=\"https://dev.to/emmanuel_xs/how-to-use-react-hook-form-with-useactionstate-hook-in-nextjs15-1hja\">https://dev.to/emmanuel_xs/how-to-use-react-hook-form-with-useactionstate-hook-in-nextjs15-1hja</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2026-01-03-nextjs-server-actions-fastapi-openapi/",
            "title": "Next.js server actions with FastAPI backend and OpenAPI client",
            "summary": "Connect Next.js to a FastAPI backend while preserving a modern React workflow with server actions and server components.\n",
            "date_modified": "2026-01-03T00:00:00.000Z",
            "date_published": "2026-01-03T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-12-21-static-website-runtime-environment-variables/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;\nimport OpenGraphBakedUrlImage from ’../../../../content/post/2025/12-21-static-website-runtime-environment-variables/_images/open-graph-baked-url.png’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>This article is a live, “try and see” practical experiment. I will use this exact blog project, a static Astro website, and try to package it as a reusable Nginx Docker image that requires just a single <code>.env</code> file to run in any environment.</p>\n<p>I will use this tutorial as a starting point: <a href=\"https://phase.dev/blog/nextjs-public-runtime-variables/\">https://phase.dev/blog/nextjs-public-runtime-variables/</a>. It describes the idea and the process, uses Next.js, and includes a shell replacement script that we can work with.</p>\n<h2 id=\"goal\">Goal</h2>\n<p>Let’s define our goal and requirements at the beginning. We will use a pure static website that consists only of assets, without any server-side runtime code. This is important because hosting a static website is simple, free, and widely available. We want reusable builds where no environment-specific data is bundled into the application code, but instead read from a single <code>.env</code> file.</p>\n<p>Now it’s time to identify which data is environment-specific. In this particular website, these are the four environment variables:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># some example values</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SITE_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">http://localhost:8080</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PLAUSIBLE_SCRIPT_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://plausible.arm1.nemanjamitic.com/js/script.js</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PLAUSIBLE_DOMAIN</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">nemanjamitic.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PREVIEW_MODE</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">true</span></span></code></pre>\n<p>Notice the format of these variables: (almost) three URLs and one boolean value.</p>\n<p>Now let’s clearly understand what makes this challenging. Once compiled, a static website becomes just a collection of static assets (<code>.html</code>, <code>.js</code>, <code>.css</code>, <code>.jpg</code>, etc.) deployed to an Nginx web folder. This practically means there is no server runtime and we cannot run any code, which greatly limits our power and control. The only runtime we have is the browser runtime, which is only useful to a limited extent when it comes to loading environment-specific data at runtime.</p>\n<h2 id=\"options\">Options</h2>\n<p>Before we start following the original tutorial and move on to the practical implementation, let’s reconsider the possible alternatives at our disposal. I already touched on this in my previous article about runtime environment variables: <a href=\"https://nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables#alternative-approaches\">https://nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables#alternative-approaches</a>, but let’s go through them once again, since this article is entirely dedicated to runtime variables in purely static websites. Here are the alternatives:</p>\n<ol>\n<li>\n<p>The first option is the original idea from the tutorial: using a shell script with the <code>sed</code> command to string-replace all placeholder values that were inlined at build time directly in the bundle. We will include this script in the Docker entrypoint so it can run when the container starts and insert environment-specific values. This way, we achieve an effect similar to start-time variables.</p>\n</li>\n<li>\n<p>Nginx has certain features for injecting environment variables into responses. For example, <code>sub_filter</code> can perform string replacement in each text response. The upside of this approach is that the variables are truly runtime and will reflect any change immediately, but the major disadvantage is the performance overhead, especially under heavy traffic.</p>\n</li>\n<li>\n<p>Another method is to rely on the JavaScript runtime in the browser and dynamically host and load <code>&#x3C;script src=\"./env.js\">&#x3C;/script></code> in the HTML of your root layout. It can have a few variations. For example, you can create an <code>env.template</code> file that holds placeholders for the replacement script called in the entrypoint, or you could even inline <code>env.js</code> directly in the <code>nginx.conf</code> itself:</p>\n</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"nginx\"><code><span class=\"line\"><span style=\"color:#F97583\">location</span><span style=\"color:#F97583\"> =</span><span style=\"color:#DBEDFF\"> /env.js </span><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  default_type </span><span style=\"color:#E1E4E8\">application/javascript;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#79B8FF\"> 200</span><span style=\"color:#9ECBFF\"> \"window.__CONFIG__ = { API_URL: '$</span><span style=\"color:#E1E4E8\">API_URL</span><span style=\"color:#9ECBFF\">' }\"</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"plaintext\"><code><span class=\"line\"><span>The common aspect of all these approaches is that you need to run client-side JavaScript on a given page to access runtime variables through the `window` object, for example `window.__RUNTIME_ENV__.MY_VAR`. This is a disadvantage on its own and comes with a performance cost. For example, Astro enforces a __zero client-side JavaScript by default__ strategy precisely for performance reasons.</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span>Another deal-breaker is that files which do not run client-side JavaScript cannot access these variables. Examples include `sitemap.xml` and `robots.txt`, which are very important SEO-related files, especially for static websites.</span></span></code></pre>\n<h2 id=\"implementation\">Implementation</h2>\n<p>And finally, the most important and interesting part: the practical implementation of the most promising alternative. We have spent quite a lot of time on the introduction and the overview of alternatives.</p>\n<p>You can review the exact implementation in the pull request at this link: <a href=\"https://github.com/nemanjam/nemanjam.github.io/pull/28\">https://github.com/nemanjam/nemanjam.github.io/pull/28</a></p>\n<h3 id=\"replacement-script\">Replacement script</h3>\n<p>Let’s start with the shell replacement script. We will use the <code>replace-variables.sh</code> script from the tutorial as a starting point. After some trial and error and a few iterations, this is what the final script looks like:</p>\n<p><a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/feature/runtime-environment-variables/scripts/replace-variables.sh\">scripts/replace-variables.sh</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\">#!/bin/sh</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Note: sh shell syntax, NO bash in Alpine Nginx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Summary:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># 1. Required variables are checked to be defined.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># 2. Optional variables are initialized to empty string if undefined.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># 3. All files in DIST_PATH with specified extensions are processed.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># 4. Placeholders of the form PREFIX_VAR are replaced with actual environment variable values.</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Define required and optional environment variables (space-separated strings for /bin/sh)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">REQUIRED_VARS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"SITE_URL PLAUSIBLE_SCRIPT_URL PLAUSIBLE_DOMAIN\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">OPTIONAL_VARS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"PREVIEW_MODE\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Variables that are baked as URL-shaped placeholders (https://BAKED_VAR)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">BAKED_URL_VARS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"SITE_URL PLAUSIBLE_SCRIPT_URL\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PREFIX</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"BAKED_\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Baked has always https://BAKED_VAR</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Will be replaced with whatever VAR value http:// or https:// or any string</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">URL_PREFIX</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"https://${</span><span style=\"color:#E1E4E8\">PREFIX</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">FILE_EXTENSIONS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"html js xml json\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Read DIST_PATH from environment variable</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Do not provide a default; it must be set</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#E1E4E8\">DIST_PATH</span><span style=\"color:#9ECBFF\">}\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"ERROR: DIST_PATH environment variable is not set.\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Check if the directory exists</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">!</span><span style=\"color:#F97583\"> -d</span><span style=\"color:#9ECBFF\"> \"${</span><span style=\"color:#E1E4E8\">DIST_PATH</span><span style=\"color:#9ECBFF\">}\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    echo</span><span style=\"color:#9ECBFF\"> \"ERROR: DIST_PATH directory '${</span><span style=\"color:#E1E4E8\">DIST_PATH</span><span style=\"color:#9ECBFF\">}' does not exist.\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Check required environment variables are defined</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> VAR </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> $REQUIRED_VARS; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # POSIX sh-compatible indirect expansion</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    eval</span><span style=\"color:#9ECBFF\"> \"VAL=</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#E1E4E8\">$VAR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$VAL</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$VAR</span><span style=\"color:#9ECBFF\"> required environment variable is not set. Please set it and rerun the script.\"</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        exit</span><span style=\"color:#79B8FF\"> 1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Default optional variables to empty string</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> VAR </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> $OPTIONAL_VARS; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    eval</span><span style=\"color:#9ECBFF\"> \"VAL=</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#E1E4E8\">$VAR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$VAL</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">        eval</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$VAR</span><span style=\"color:#9ECBFF\">=''\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    fi</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Combine required and optional variables into a single string</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">ALL_VARS</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$REQUIRED_VARS</span><span style=\"color:#E1E4E8\"> $OPTIONAL_VARS</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Find and replace placeholders in files</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">for</span><span style=\"color:#E1E4E8\"> ext </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> $FILE_EXTENSIONS; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Use 'find' to recursively search for all files with the current extension</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # -type f ensures only regular files are returned</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # -name \"*.$ext\" matches files ending with the current extension</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    find</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$DIST_PATH</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#79B8FF\"> -type</span><span style=\"color:#9ECBFF\"> f</span><span style=\"color:#79B8FF\"> -name</span><span style=\"color:#9ECBFF\"> \"*.</span><span style=\"color:#E1E4E8\">$ext</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Pipe the list of found files into a while loop for processing</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    while</span><span style=\"color:#79B8FF\"> read</span><span style=\"color:#79B8FF\"> -r</span><span style=\"color:#9ECBFF\"> file</span><span style=\"color:#E1E4E8\">; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Read file once into a variable for faster checks</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        FILE_CONTENT</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">cat</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        FILE_REPLACED</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        # Loop over each variable that needs to be replaced</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        for</span><span style=\"color:#E1E4E8\"> VAR </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> $ALL_VARS; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            PLACEHOLDER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">PREFIX</span><span style=\"color:#9ECBFF\">}${</span><span style=\"color:#E1E4E8\">VAR</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            URL_PLACEHOLDER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">URL_PREFIX</span><span style=\"color:#9ECBFF\">}${</span><span style=\"color:#E1E4E8\">VAR</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Get variable value (POSIX sh compatible)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Optional variables are guaranteed to have a value (possibly empty)</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            eval</span><span style=\"color:#9ECBFF\"> \"VALUE=</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#E1E4E8\">$VAR</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Escape VALUE for sed replacement:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # - &#x26; → \\&#x26;  (ampersand is special in replacement, expands to the whole match)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # - | → \\|  (pipe is used as sed delimiter)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            ESCAPED_VALUE</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#79B8FF\">printf</span><span style=\"color:#9ECBFF\"> '%s'</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$VALUE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> sed</span><span style=\"color:#9ECBFF\"> 's/[&#x26;|]/\\\\&#x26;/g'</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Handle baked URL variables (e.g. https://BAKED_SITE_URL)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # These must be replaced as full URLs to avoid invalid or double protocols</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            for</span><span style=\"color:#E1E4E8\"> URL_VAR </span><span style=\"color:#F97583\">in</span><span style=\"color:#E1E4E8\"> $BAKED_URL_VARS; </span><span style=\"color:#F97583\">do</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                # Check if current variable is a baked URL var</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$VAR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$URL_VAR</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                    # Skip if URL placeholder is not present in this file, 2 - parent loop, i - case insensitive</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">                    echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILE_CONTENT</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#79B8FF\"> -qi</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$URL_PLACEHOLDER</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#F97583\"> continue</span><span style=\"color:#79B8FF\"> 2</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                    # Print file name once on first replacement</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                    if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$FILE_REPLACED</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> -eq</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">                        echo</span><span style=\"color:#9ECBFF\"> \"Processing file: </span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                        FILE_REPLACED</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                    fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                    # Log replacement</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                    # Log $VALUE, because $ESCAPED_VALUE is just for sed</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">                    echo</span><span style=\"color:#9ECBFF\"> \"replaced: </span><span style=\"color:#E1E4E8\">$URL_PLACEHOLDER</span><span style=\"color:#9ECBFF\"> -> </span><span style=\"color:#E1E4E8\">$VALUE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                    # Replace full URL placeholder in-place, I - case insensitive</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">                    sed</span><span style=\"color:#79B8FF\"> -i</span><span style=\"color:#9ECBFF\"> \"s|</span><span style=\"color:#E1E4E8\">$URL_PLACEHOLDER</span><span style=\"color:#9ECBFF\">|</span><span style=\"color:#E1E4E8\">$ESCAPED_VALUE</span><span style=\"color:#9ECBFF\">|gI\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">                    # Continue with next variable, 2 - parent loop</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                    continue</span><span style=\"color:#79B8FF\"> 2</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">                fi</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            done</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Note: exits loop early if placeholder is not present in the file, i - case insensitive</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">            echo</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$FILE_CONTENT</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> |</span><span style=\"color:#B392F0\"> grep</span><span style=\"color:#79B8FF\"> -qi</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$PLACEHOLDER</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> ||</span><span style=\"color:#F97583\"> continue</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Print file name only on first replacement</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\">$FILE_REPLACED</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#F97583\"> -eq</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">                echo</span><span style=\"color:#9ECBFF\"> \"Processing file: </span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">                FILE_REPLACED</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">1</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Log what is replaced</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            if</span><span style=\"color:#E1E4E8\"> [ </span><span style=\"color:#F97583\">-z</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$VALUE</span><span style=\"color:#9ECBFF\">\"</span><span style=\"color:#E1E4E8\"> ]; </span><span style=\"color:#F97583\">then</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">                echo</span><span style=\"color:#9ECBFF\"> \"replaced: </span><span style=\"color:#E1E4E8\">$PLACEHOLDER</span><span style=\"color:#9ECBFF\"> -> (empty)\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            else</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">                echo</span><span style=\"color:#9ECBFF\"> \"replaced: </span><span style=\"color:#E1E4E8\">$PLACEHOLDER</span><span style=\"color:#9ECBFF\"> -> </span><span style=\"color:#E1E4E8\">$VALUE</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">            fi</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Perform in-place replacement using sed</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # \"s|pattern|replacement|g\" replaces all occurrences in the file</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # The | delimiter is used instead of / to avoid conflicts with paths</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # I - case insensitive</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            # Example: BAKED_SITE_URL → https://example.com</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            sed</span><span style=\"color:#79B8FF\"> -i</span><span style=\"color:#9ECBFF\"> \"s|</span><span style=\"color:#E1E4E8\">$PLACEHOLDER</span><span style=\"color:#9ECBFF\">|</span><span style=\"color:#E1E4E8\">$ESCAPED_VALUE</span><span style=\"color:#9ECBFF\">|gI\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">        done</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    done</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">done</span></span></code></pre>\n<p>I left the verbose comments in the script for clarity, but here are the most important points to keep in mind:</p>\n<ul>\n<li>It uses <code>#!/bin/sh</code> shell syntax because <code>bash</code> is not available by default in the Nginx Alpine image, and we want to keep the image size minimal.</li>\n<li>It uses the <code>DIST_PATH</code> environment variable as an argument to pass the path to the bundle into the script. At the top of the script, we validate and initialize all hardcoded and passed arguments, and exit early if invalid data is provided.</li>\n<li>We support and handle required and optional variables separately via <code>REQUIRED_VARS</code> and <code>OPTIONAL_VARS</code>.</li>\n<li>This is the important part: we treat ordinary string variables and “URL-shaped” string variables separately, assign them distinct identifying prefixes (<code>PREFIX=\"BAKED_\"</code> and <code>URL_PREFIX=\"https://${PREFIX}\"</code>), and use the corresponding placeholders (<code>PLACEHOLDER=\"${PREFIX}${VAR}\"</code> and <code>URL_PLACEHOLDER=\"${URL_PREFIX}${VAR}\"</code>). This is necessary because a baked build for Astro will fail if we pass an invalid URL to the <code>site</code> option in <code>astro.config.ts</code>.</li>\n<li>We continue to use <code>sed</code> instead of <code>envsubst</code> because it gives us more control over string replacement. Additionally, we escape special characters such as <code>&#x26;</code> and <code>|</code> in the <code>sed</code> input.</li>\n<li>We log processed files and replaced variables for debugging and monitoring purposes.</li>\n</ul>\n<h3 id=\"nginx-image-entrypoint\">Nginx image entrypoint</h3>\n<p>Once we have a working and tested replacement script, it’s time to include it in the Docker entrypoint so it can run when the container starts and replace baked placeholders in the bundle with the actual environment variables from the current environment.</p>\n<p>Fortunately, the Nginx Alpine image already provides a dedicated pre-start folder, <code>/docker-entrypoint.d</code>, intended for entrypoint scripts. We define the <code>ENV DIST_PATH=/usr/share/nginx/html</code> environment variable because <code>replace-variables.sh</code> expects it as an input argument. Additionally, we include a <code>10-</code> prefix in the script file name to define the execution order of scripts in the entrypoint. We want our script to run before any others.</p>\n<p>Below is the complete <code>runner</code> stage of the <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/feature/runtime-environment-variables/docker/Dockerfile\">docker/Dockerfile</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"dockerfile\"><code><span class=\"line\"><span style=\"color:#6A737D\"># -------------- runner --------------</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">FROM</span><span style=\"color:#E1E4E8\"> nginx:1.29.1-alpine3.22-slim </span><span style=\"color:#F97583\">AS</span><span style=\"color:#E1E4E8\"> runtime</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">COPY</span><span style=\"color:#E1E4E8\"> ./docker/nginx.conf /etc/nginx/nginx.conf</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># set dist folder path for both web folder and script arg</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> DIST_PATH=/usr/share/nginx/html</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># sufficient for SSG</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">COPY</span><span style=\"color:#E1E4E8\"> --from=build /app/dist ${DIST_PATH}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># copy to pre-start scripts folder</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># 10-xxx controls the order</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">COPY</span><span style=\"color:#E1E4E8\"> ./scripts/replace-variables.sh /docker-entrypoint.d/10-replace-variables.sh</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> chmod +x /docker-entrypoint.d/10-replace-variables.sh</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">EXPOSE</span><span style=\"color:#E1E4E8\"> 8080</span></span></code></pre>\n<h3 id=\"setting-the-variables-for-test\">Setting the variables for test</h3>\n<p>Finally, we define the actual values for the environment variables in the <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/feature/runtime-environment-variables/docker-compose.yml\">docker-compose.yml</a> for testing:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  nmc-docker</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">nmc-docker</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    build</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      context</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">.</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      dockerfile</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">./docker/Dockerfile</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    platform</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linux/amd64</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      SITE_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'http://localhost:8080'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      PLAUSIBLE_SCRIPT_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://plausible.arm1.nemanjamitic.com/js/script.js'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      PLAUSIBLE_DOMAIN</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'nemanjamitic.com'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      PREVIEW_MODE</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'true'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'8080:8080'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">default</span></span></code></pre>\n<p>With this, we are all set to run the container and apply the start-time environment variables. The original tutorial mentions certain trade-offs of this method, such as slower container startup and the risk of unintentional string replacements, but these are tolerable for our use case. But let’s actually see if there is more to it, in detail, in the next section.</p>\n<h2 id=\"issues\">Issues</h2>\n<p>This is by far the most important section of the article. If you wanted a “TLDR” of the article, this would be it. Let’s review the issues one by one:</p>\n<h3 id=\"you-can-have-only-string-variables\">You can have only string variables</h3>\n<p>You can have only string variables (or unions of string literals - enums). Let’s illustrate this with a code example:</p>\n<p>My website uses one boolean variable, <code>PLAUSIBLE_DOMAIN</code>, that enables preview of draft articles. Initially, it’s typed and validated as a boolean in both Zod and Astro schemas:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Zod schema</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> booleanValues</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'true'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'false'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> processEnvSchema</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> z.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  PREVIEW_MODE: z</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">enum</span><span style=\"color:#E1E4E8\">(booleanValues)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">transform</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">value</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> value </span><span style=\"color:#F97583\">===</span><span style=\"color:#9ECBFF\"> 'true'</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">default</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Astro schema</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> envSchema</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  schema: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    PREVIEW_MODE: envField.</span><span style=\"color:#B392F0\">boolean</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      context: </span><span style=\"color:#9ECBFF\">'server'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      access: </span><span style=\"color:#9ECBFF\">'public'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      default: </span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }),</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span></code></pre>\n<p>But obviously, since our replacement method requires unique, baked placeholder values, we cannot use <code>true</code> and <code>false</code> directly. We must convert it to a union of string literals so that the baked placeholder value is valid at build time and the build can succeed. The type becomes: <code>PREVIEW_MODE: 'true' | 'false' | '' | 'BAKED_PREVIEW_MODE'</code>. Here is the updated code:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Zod schema</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/utils/baked.ts</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> baked</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> &#x3C;</span><span style=\"color:#B392F0\">T</span><span style=\"color:#F97583\"> extends</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">>(</span><span style=\"color:#FFAB70\">name</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> T</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#9ECBFF\"> `BAKED_${</span><span style=\"color:#B392F0\">T</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#F97583\"> =></span><span style=\"color:#9ECBFF\"> `BAKED_${</span><span style=\"color:#E1E4E8\">name</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#F97583\"> as</span><span style=\"color:#9ECBFF\"> `BAKED_${</span><span style=\"color:#B392F0\">T</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> booleanValues</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'true'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'false'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> processEnvSchema</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> z.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Note: string union, not boolean, for baked</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  PREVIEW_MODE: z</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">enum</span><span style=\"color:#E1E4E8\">(booleanValues)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">or</span><span style=\"color:#E1E4E8\">(z.</span><span style=\"color:#B392F0\">literal</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">baked</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'PREVIEW_MODE'</span><span style=\"color:#E1E4E8\">)))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">default</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'false'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Astro schema</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/utils/baked.ts</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> baked</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> &#x3C;</span><span style=\"color:#B392F0\">T</span><span style=\"color:#F97583\"> extends</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">>(</span><span style=\"color:#FFAB70\">name</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> T</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#9ECBFF\"> `BAKED_${</span><span style=\"color:#B392F0\">T</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#F97583\"> =></span><span style=\"color:#9ECBFF\"> `BAKED_${</span><span style=\"color:#E1E4E8\">name</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#F97583\"> as</span><span style=\"color:#9ECBFF\"> `BAKED_${</span><span style=\"color:#B392F0\">T</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> envSchema</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  schema: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    PREVIEW_MODE: envField.</span><span style=\"color:#B392F0\">enum</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      context: </span><span style=\"color:#9ECBFF\">'server'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      access: </span><span style=\"color:#9ECBFF\">'public'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      values: [</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">booleanValues, </span><span style=\"color:#B392F0\">baked</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'PREVIEW_MODE'</span><span style=\"color:#E1E4E8\">)],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      default: </span><span style=\"color:#9ECBFF\">'false'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }),</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span></code></pre>\n<p>And here is an example of how to use the new quasi-boolean variable:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> isPreviewMode</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> ()</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> boolean</span><span style=\"color:#F97583\"> =></span><span style=\"color:#79B8FF\"> CONFIG_SERVER</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">PREVIEW_MODE</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'true'</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p><strong>Issue no. 1 conclusion:</strong> It’s a bit of a workaround, but acceptable.</p>\n<h3 id=\"you-must-handle-url-shaped-variables-separately\">You must handle URL-shaped variables separately</h3>\n<p>You must bake and replace URL-shaped variables separately for the build to pass. The most typical and obvious variable is <code>SITE_URL</code>, which is assigned to the <code>site:</code> option inside <code>astro.config.ts</code>. This option is deeply integrated into the framework, used for routing, and passed within default <code>Astro.props</code>. If left <code>undefined</code>, Astro defaults to <code>http://localhost:port</code>. On the other hand, if you set it to a non-URL baked placeholder, e.g., <code>BAKED_SITE_URL</code>, the build will fail, as Astro internally passes it into the native <code>new URL()</code> constructor.</p>\n<p>We solve this by treating URL-shaped variables separately, giving them a different prefix and replacement rule. This way, a baked placeholder can be a valid URL, e.g., <code>https://BAKED_SITE_URL</code>, allowing the build to succeed.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">PREFIX</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"BAKED_\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">URL_PREFIX</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"https://${</span><span style=\"color:#E1E4E8\">PREFIX</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PLACEHOLDER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">PREFIX</span><span style=\"color:#9ECBFF\">}${</span><span style=\"color:#E1E4E8\">VAR</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">URL_PLACEHOLDER</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"${</span><span style=\"color:#E1E4E8\">URL_PREFIX</span><span style=\"color:#9ECBFF\">}${</span><span style=\"color:#E1E4E8\">VAR</span><span style=\"color:#9ECBFF\">}\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sed</span><span style=\"color:#79B8FF\"> -i</span><span style=\"color:#9ECBFF\"> \"s|</span><span style=\"color:#E1E4E8\">$PLACEHOLDER</span><span style=\"color:#9ECBFF\">|</span><span style=\"color:#E1E4E8\">$ESCAPED_VALUE</span><span style=\"color:#9ECBFF\">|gI\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sed</span><span style=\"color:#79B8FF\"> -i</span><span style=\"color:#9ECBFF\"> \"s|</span><span style=\"color:#E1E4E8\">$URL_PLACEHOLDER</span><span style=\"color:#9ECBFF\">|</span><span style=\"color:#E1E4E8\">$ESCAPED_VALUE</span><span style=\"color:#9ECBFF\">|gI\"</span><span style=\"color:#9ECBFF\"> \"</span><span style=\"color:#E1E4E8\">$file</span><span style=\"color:#9ECBFF\">\"</span></span></code></pre>\n<p>Fortunately, Astro doesn’t transform the <code>site:</code> option internally, so the placeholder maintains its integrity and this works as expected.</p>\n<p><strong>Issue no. 2 conclusion:</strong> It was a close call, but it works and is acceptable.</p>\n<h3 id=\"open-graph-images-with-runtime-data-are-impossible\">Open Graph images with runtime data are impossible</h3>\n<p>This issue was somewhat obvious, but I still failed to predict it. Open Graph images are typically very important for SEO and the reach of static websites, especially for blogs or content-focused sites whose success largely depends on sharing on social networks.</p>\n<p>One obvious piece of information that an Open Graph image should include is the website URL. Since we made <code>SITE_URL</code> a start-time variable, only its placeholder is available at build time.</p>\n<p>On my website, I use an Astro static endpoint in <a href=\"https://gitlab.com/nemanjam/nemanjam.github.io/-/blob/feature/runtime-environment-variables/src/pages/api/open-graph/%5B...route%5D.png.ts\">src/pages/api/open-graph/[…route].png.ts</a> to dynamically render <code>.png</code> images from a Satori HTML template. This endpoint is called at build time, and the rendered <code>.png</code> images are included in the bundle. There is nothing we can do at runtime.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={OpenGraphBakedUrlImage} alt=“Open Graph image with incorrect SITE_URL placeholder” /></p>\n<p>The obvious consequence is that we can either:</p>\n<ol>\n<li>Omit runtime data (<code>SITE_URL</code>) from the Open Graph images, or use a unique and consistent <code>SITE_URL_CANONICAL</code> for all environments.</li>\n<li>Externalize the Open Graph endpoint and implement it as a dynamic API endpoint with a full Node.js runtime that reads the request object and renders images dynamically. This would require a separate backend app and hosting, and the complexity of this setup outweighs the complexity of rebuilding the images for each environment.</li>\n</ol>\n<p>Obviously, both options 1 and 2 are bad trade-offs and beyond what’s acceptable. At this point, it’s better to keep the existing setup and rebuild the website for each environment.</p>\n<p><strong>Issue no. 3 conclusion:</strong> It is unacceptable.</p>\n<h3 id=\"you-must-transform-variables-in-client-side-javascript\">You must transform variables in client-side JavaScript</h3>\n<p>Often, you need to transform a URL variable, for example to extract the domain from the URL. Obviously, you can’t do this in Astro TypeScript code that runs at build time, because it would use values from the baked placeholders and inline the incorrect placeholder domain into the bundle. You must keep the baked variable intact during the build.</p>\n<p>The solution is to move that transformation to client-side JavaScript by including a <code>&#x3C;script /></code> with the transformation code that runs on page load. As mentioned earlier, this degrades page performance and SEO, because the client needs to parse and run the JavaScript to get the final content of the page.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"html\"><code><span class=\"line\"><span style=\"color:#6A737D\">&#x3C;!-- title attribute needs just the domain from the SITE_URL --></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">link</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  id</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"rss-link\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  rel</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"alternate\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  type</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"application/rss+xml\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  data-SITE_URL</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">{SITE_URL}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  title</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"RSS feed\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  href</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"/api/feed/rss\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">/></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // or read it form DOM and data-SITE_URL attribute</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> siteUrl</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> window.__RUNTIME_ENV__.</span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> hostname</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> URL</span><span style=\"color:#E1E4E8\">(siteUrl).hostname;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> link</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> document.</span><span style=\"color:#B392F0\">getElementById</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'rss-link'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (link </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> hostname) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    link.title </span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\"> 'RSS feed for '</span><span style=\"color:#F97583\"> +</span><span style=\"color:#E1E4E8\"> hostname;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>As mentioned, this makes the code messy, overly verbose, and error-prone. It degrades page performance and SEO, and defeats Astro’s <strong>zero client-side JavaScript by default</strong> strategy. All of this is, once again, beyond acceptable.</p>\n<p><strong>Issue no. 4 conclusion:</strong> It is unacceptable.</p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Pull request:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io/pull/28\">https://github.com/nemanjam/nemanjam.github.io/pull/28</a></li>\n</ul>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>The experiment partially worked, but the results clearly show why a reusable build for <strong>a pure static website</strong> is a bad idea in practice.</p>\n<p>It is possible to inject start-time environment variables into a static bundle using shell scripts, Nginx entrypoints, and carefully crafted placeholders. With enough discipline, you can even make builds pass by separating string variables from URL-shaped variables, bending schemas, and moving certain logic into client-side JavaScript. However, every step in that direction erodes the very benefits that make static websites attractive in the first place.</p>\n<p>A pure static website has no server runtime, no request context, and no dynamic execution environment. As soon as you try to retrofit runtime configuration into that model, you run into hard limitations. Non-string values must be faked, URLs must be handled as special cases, Open Graph images become impossible to render correctly, and any transformation of environment data leaks into client-side JavaScript. At that point, you are no longer building a clean static site, but a fragile system of workarounds that hurts performance, SEO, and maintainability.</p>\n<p>If you already have a dynamic, server-side rendered website that uses a server runtime and only need a few static pages, then a few workarounds to benefit from runtime environment variables and reusable builds can represent a reasonable trade-off. I already described that use case in the previous article: <a href=\"https://nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables\">https://nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables</a>.</p>\n<p>On the other hand, if you want the simplicity, performance, and reliability of a pure static website, then accept rebuilds as part of the workflow.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Starting point tutorial: <a href=\"https://phase.dev/blog/nextjs-public-runtime-variables/\">https://phase.dev/blog/nextjs-public-runtime-variables/</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-12-21-static-website-runtime-environment-variables/",
            "title": "Why runtime environment variables for a pure static website are a bad idea",
            "summary": "Build and deploy a static website as a reusable Docker image, and see how practical it really is.\n",
            "date_modified": "2025-12-21T00:00:00.000Z",
            "date_published": "2025-12-21T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables/",
            "content_html": "<h2 id=\"classification-of-environment-variables-by-dimension\">Classification of environment variables by dimension</h2>\n<p>At first glance, you might think of environment variables as just a few values needed when the app starts, but as you dig deeper, you realize it’s far more complex than that. If you don’t clearly understand the nature of the value you’re dealing with, you’ll have a hard time running the app and managing its configuration across multiple environments.</p>\n<p>Let’s identify a few dimensions that any environment variable can have:</p>\n<ol>\n<li><strong>When:</strong> build-time, start-time, run-time</li>\n<li><strong>Where:</strong> server (static, SSR (request), ISR), client</li>\n<li><strong>Visibility:</strong> public, private</li>\n<li><strong>Requirement:</strong> optional, required</li>\n<li><strong>Scope:</strong> common for all environments (constant, config), unique</li>\n<li><strong>Mutability:</strong> constant, mutable</li>\n<li><strong>Git tracking:</strong> versioned, ignored</li>\n</ol>\n<p>There are probably more, but this is enough to understand why it can be challenging to manage. We could go very wide, write a long article and elaborate each of these and their combinations, but since the goal of this article is very specific and practical - handling Next.js environment variables in Docker, we’ll focus just on the top three items from the list. Still, it was worth mentioning the others for context.</p>\n<h2 id=\"nextjs-environment-variables\">Next.js environment variables</h2>\n<p>If you search the Next.js docs, you will find a <a href=\"https://nextjs.org/docs/app/guides/environment-variables#runtime-environment-variables\">guide on environment variables</a>, such as <code>.env*</code> filenames that are loaded by default, their load order and priority, variable expansion, and exposing and inlining variables with the <code>NEXT_PUBLIC_</code> prefix into the client. In the <a href=\"https://nextjs.org/docs/app/guides/self-hosting#environment-variables\">self-hosting guide</a>, you will also find a paragraph about opting into dynamic rendering so that variable values are read on each server component render, not just once at build time, and how this is useful for reusable Docker images.</p>\n<h2 id=\"the-problem-with-build-time-environment-variables\">The problem with build-time environment variables</h2>\n<p>A common scenario after reading the docs is to be aware of <code>NEXT_PUBLIC_</code> and server variables and then scatter them around the codebase. If you use Docker and GitHub Actions, you will typically end up with something like this:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/11c8a512f57e937aa623776418fa4cfb1e9b4dc4/frontend/Dockerfile\">11c8a512…/frontend/Dockerfile</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"dockerfile\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Next.js app installer stage</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">FROM</span><span style=\"color:#E1E4E8\"> base </span><span style=\"color:#F97583\">AS</span><span style=\"color:#E1E4E8\"> installer</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> apk update</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> apk add --no-cache libc6-compat</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Enable pnpm</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> PNPM_HOME=</span><span style=\"color:#9ECBFF\">\"/pnpm\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> PATH=</span><span style=\"color:#9ECBFF\">\"$PNPM_HOME:$PATH\"</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> corepack enable</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> corepack prepare pnpm@10.12.4 --activate</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">WORKDIR</span><span style=\"color:#E1E4E8\"> /app</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Copy monorepo package.json and lock files</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">COPY</span><span style=\"color:#E1E4E8\"> --from=builder /app/out/json/ .</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Install the dependencies</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> pnpm install --frozen-lockfile</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Copy pruned source</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">COPY</span><span style=\"color:#E1E4E8\"> --from=builder /app/out/full/ .</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># THIS: set build time env vars</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ARG</span><span style=\"color:#E1E4E8\"> ARG_NEXT_PUBLIC_SITE_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> NEXT_PUBLIC_SITE_URL=$ARG_NEXT_PUBLIC_SITE_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> echo </span><span style=\"color:#9ECBFF\">\"NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">ARG</span><span style=\"color:#E1E4E8\"> ARG_NEXT_PUBLIC_API_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> NEXT_PUBLIC_API_URL=$ARG_NEXT_PUBLIC_API_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> echo </span><span style=\"color:#9ECBFF\">\"NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Build the project</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> pnpm turbo build</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span></code></pre>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/11c8a512f57e937aa623776418fa4cfb1e9b4dc4/.github/workflows/build-push-frontend.yml\">11c8a512…/.github/workflows/build-push-frontend.yml</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Build and push Docker frontend</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">on</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  push</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    branches</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'main'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  workflow_dispatch</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">env</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  IMAGE_NAME</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ github.event.repository.name }}-frontend</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # THIS: set build time env vars</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  NEXT_PUBLIC_SITE_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  NEXT_PUBLIC_API_URL</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'https://api.full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">jobs</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  build</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Build and push docker image</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    runs-on</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">ubuntu-latest</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    steps</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#85E89D\">name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">Build and push Docker image</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        uses</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">docker/build-push-action@v6</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">        with</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          context</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">./frontend</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          file</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">./frontend/Dockerfile</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          platforms</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linux/amd64,linux/arm64</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          progress</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">plain</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">          # THIS: set build time args</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          build-args</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#F97583\">|</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            \"ARG_NEXT_PUBLIC_SITE_URL=${{ env.NEXT_PUBLIC_SITE_URL }}\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">            \"ARG_NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }}\"</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          push</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">          tags</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      </span></span></code></pre>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/11c8a512f57e937aa623776418fa4cfb1e9b4dc4/frontend/package.json\">11c8a512…/frontend/package.json</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"name\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"full-stack-fastapi-template-nextjs\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"version\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"0.0.1\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"private\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"scripts\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"build\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"turbo build\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"dev\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"turbo dev\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"standalone\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"turbo run standalone --filter web\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // THIS: set build time args</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">    \"docker:build:x86\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"docker buildx build -f ./Dockerfile -t nemanjamitic/full-stack-fastapi-template-nextjs-frontend --build-arg ARG_NEXT_PUBLIC_SITE_URL='full-stack-fastapi-template-nextjs.local.nemanjamitic.com' --build-arg ARG_NEXT_PUBLIC_API_URL='api.full-stack-fastapi-template-nextjs.local.nemanjamitic.com' --platform linux/amd64 .\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>In the code above, we can see that our Next.js app requires the <code>NEXT_PUBLIC_SITE_URL</code> and <code>NEXT_PUBLIC_API_URL</code> environment variables at build time. These values will be inlined into the bundle during the build and cannot be changed later. This means the <code>Dockerfile</code> must pass them as the corresponding <code>ARG_NEXT_PUBLIC_SITE_URL</code> and <code>ARG_NEXT_PUBLIC_API_URL</code> build arguments when building the image.</p>\n<p>Leaving them undefined would break the build because they are validated with Zod inside the Next.js app, and validation runs at both build time and run time. Stripping the <code>NEXT_PUBLIC_</code> prefix would also break the build, even without Zod, if they are used in client code.</p>\n<p>Consequently, we need to pass these build arguments whenever we build the Docker image, for example in GitHub Actions and in the local build script defined in <code>package.json</code>.</p>\n<p>Using this method, we would get a functional Docker image, but with one major drawback: <strong>it can be used only in a single environment</strong> because the <code>NEXT_PUBLIC_SITE_URL</code> and <code>NEXT_PUBLIC_API_URL</code> values are baked into the image at build time and are immutable.</p>\n<p>To make this crystal clear, whatever we set for the <code>NEXT_PUBLIC_SITE_URL</code> and <code>NEXT_PUBLIC_API_URL</code> environment variables at runtime will be ignored because they no longer exist in the Next.js app. After the build they are replaced with string literals in the JavaScript bundle.</p>\n<p>If, besides production, you also have staging, preview, testing environments, or other production mirrors, you would need to maintain a separate image with its own configuration code, build process, and registry storage for each of them. This means a lot of overhead.</p>\n<p>Many people find this impractical, which you can see from the popularity of such issues in the Next.js repository:</p>\n<p><a href=\"https://github.com/vercel/next.js/discussions/44628\">Better support for runtime environment variables #44628</a></p>\n<p><a href=\"https://github.com/vercel/next.js/discussions/17641\">Docker image with NEXT_PUBLIC_ env variables #17641</a></p>\n<p><a href=\"https://github.com/vercel/next.js/discussions/22243\">Not possible to use different configurations in staging + production #22243</a></p>\n<h2 id=\"the-solution-run-time-environment-variables\">The solution: run-time environment variables</h2>\n<p>The solution is obvious: we should prevent any use of build-time (stale, immutable) variables and read everything from the target environment at runtime. This also means avoiding any <code>NEXT_PUBLIC_*</code> client variables.</p>\n<p>To implement this, we must be well aware of where and when a given component runs:</p>\n<ol>\n<li>Server component - runs on the server, generated at build time or at request time</li>\n<li>Static page - runs on the server, generated once at build time</li>\n<li>Client component - runs in the browser, generated at build time or at request time</li>\n</ol>\n<h3 id=\"server-component\">Server component</h3>\n<p>These components (or entire pages) are dynamically rendered on each request. They have access to any server data, including both public and private environment variables. No additional action is needed. In Next.js, we identify such components by their use of request resources such as cookies, headers, and connection:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cookies, headers } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/headers'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { connection } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/server'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> async</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> Page</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> headersList</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> headers</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> cookiesList</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> cookies</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  await</span><span style=\"color:#B392F0\"> connection</span><span style=\"color:#E1E4E8\">(); </span><span style=\"color:#6A737D\">// void</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"static-page\">Static page</h3>\n<p>Such a page is pre-rendered once at build time in the build environment. It has access to server data, but it is converted to a static asset at build time and is immutable at runtime. We have two options:</p>\n<ol>\n<li>Convert it to a dynamic page that is rendered on the server on each request.</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { connection } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/server'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#F97583\"> async</span><span style=\"color:#F97583\"> function</span><span style=\"color:#B392F0\"> Page</span><span style=\"color:#E1E4E8\">() {</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // opt into dynamic rendering</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  await</span><span style=\"color:#B392F0\"> connection</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<ol start=\"2\">\n<li>\n<p>Set <strong>placeholder values</strong> for variables at build time and perform string replacement directly on the generated static HTML using <code>sed</code> or <code>envsubst</code> and a shell script included in <code>ENTRYPOINT [\"scripts/entrypoint.sh\"]</code> in the Dockerfile.</p>\n<p>Note that these will be <strong>start-time</strong> variables, not true <strong>run-time</strong> variables, but most of the time that is sufficient because they are unique to each environment. However, they cannot change during the app’s run time once initialized.</p>\n<p>We won’t go into much detail about this method, it could be a good topic for a future article since it is quite useful for static, presentational websites. If you want to read more, here is an interesting and practical tutorial: <a href=\"https://phase.dev/blog/nextjs-public-runtime-variables/\">https://phase.dev/blog/nextjs-public-runtime-variables/</a>.</p>\n</li>\n</ol>\n<h3 id=\"client-component\">Client component</h3>\n<p>Next.js prevents exposing any variables to the client without the <code>NEXT_PUBLIC_</code> prefix, but since those are inlined at build time, we simply won’t use them. For exposing environment variables to client components, we have a few options:</p>\n<ol>\n<li>\n<p>Pass variables as props from the parent server component like any other value. This is simple and convenient.</p>\n</li>\n<li>\n<p>Inside the dynamically generated root layout, render a <code>&#x3C;script /></code> tag that injects a <code>window.__RUNTIME_ENV__</code> property into the global <code>window</code> object using the <code>dangerouslySetInnerHTML</code> attribute. We will actually use this method. Then, on the client, we can access the variables on the <code>window</code> object, for example <code>window.__RUNTIME_ENV__.API_URL</code>.</p>\n<p>Also this is a good moment to validate runtime vars with Zod.</p>\n<p>Here is the illustration code bellow:</p>\n</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { connection } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'next/server'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> runtimeEnvSchema</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> z.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  SITE_URL: z.</span><span style=\"color:#B392F0\">url</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">regex</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#79B8FF\">[</span><span style=\"color:#F97583\">^</span><span style=\"color:#79B8FF\">/]</span><span style=\"color:#F97583\">$</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'SITE_URL should not end with a slash \"/\"'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  API_URL: z.</span><span style=\"color:#B392F0\">url</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">regex</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#79B8FF\">[</span><span style=\"color:#F97583\">^</span><span style=\"color:#79B8FF\">/]</span><span style=\"color:#F97583\">$</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'API_URL should not end with a slash \"/\"'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> RootLayout</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#FFAB70\">children</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  await</span><span style=\"color:#B392F0\"> connection</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> runtimeEnvData</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SITE_URL: process.env.</span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    API_URL: process.env.</span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // validate vars with Zod before injecting</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> parsedRuntimeEnv</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> runtimeEnvSchema.</span><span style=\"color:#B392F0\">safeParse</span><span style=\"color:#E1E4E8\">(runtimeEnvData);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // if invalid vars abort</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">parsedRuntimeEnv.success) </span><span style=\"color:#F97583\">throw</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Error</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'Invalid runtime environment variable found...'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> runtimeEnv</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> parsedRuntimeEnv.data;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">html</span><span style=\"color:#B392F0\"> lang</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"en\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#85E89D\">body</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {</span><span style=\"color:#6A737D\">/* Inline JSON injection */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#85E89D\">script</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          dangerouslySetInnerHTML</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            __html: </span><span style=\"color:#9ECBFF\">`window.__RUNTIME_ENV__ = ${</span><span style=\"color:#79B8FF\">JSON</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#B392F0\">stringify</span><span style=\"color:#9ECBFF\">(</span><span style=\"color:#E1E4E8\">runtimeEnv</span><span style=\"color:#9ECBFF\">)</span><span style=\"color:#9ECBFF\">};`</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          }}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {children}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#85E89D\">body</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">html</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<ol start=\"3\">\n<li>Same as for static pages: set <strong>placeholder values</strong> and use <code>sed</code> to replace them with a shell script inside the JavaScript bundle when the container starts.</li>\n<li>Expose variables through a dynamic API endpoint and perform an HTTP fetch in client components. This is a legitimate method, but note that it will make the variables asynchronous.</li>\n</ol>\n<p>We can see from this that the first two methods are the simplest and most convenient, so we will use them.</p>\n<p><strong>Note:</strong> Whenever an environment variable is available on the client, it is public by default. Make sure not to expose any secrets to the client.</p>\n<h3 id=\"alizeaitnext-public-env-package\"><code>alizeait/next-public-env</code> package</h3>\n<p>We could do this manually as shown in the snippet above, but there is already the <a href=\"https://github.com/alizeait/next-public-env\">alizeait/next-public-env</a> package that handles all of this and also provides some more advanced handling.</p>\n<p>Check these 2 files for example:</p>\n<ul>\n<li><a href=\"https://github.com/alizeait/next-public-env/blob/master/packages/next-public-env/src/server/FlushConfig.tsx\">src/server/FlushConfig.tsx</a></li>\n<li><a href=\"https://github.com/alizeait/next-public-env/blob/master/packages/next-public-env/src/server/index.tsx#L130\">src/server/index.tsx#L130</a></li>\n</ul>\n<p>Usage is obvious and straightforward: just define a Zod schema, mount <code>&#x3C;PublicEnv /></code> in the root layout, and use <code>getPublicEnv()</code> to access the variables wherever you need them.</p>\n<p>You can see bellow how I did it:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># install package</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">pnpm</span><span style=\"color:#9ECBFF\"> add</span><span style=\"color:#9ECBFF\"> next-public-env</span></span></code></pre>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/config/process-env.ts\">frontend/apps/web/src/config/process-env.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">/** Exports RUNTIME env. Must NOT call getPublicEnv() in global scope. */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">getPublicEnv</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">PublicEnv</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> createPublicEnv</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    NODE_ENV: process.env.</span><span style=\"color:#79B8FF\">NODE_ENV</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SITE_URL: process.env.</span><span style=\"color:#79B8FF\">SITE_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    API_URL: process.env.</span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  { </span><span style=\"color:#B392F0\">schema</span><span style=\"color:#E1E4E8\">: (</span><span style=\"color:#FFAB70\">z</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#B392F0\"> getProcessEnvSchemaProps</span><span style=\"color:#E1E4E8\">(z) }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span></code></pre>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/schemas/config.ts\">frontend/apps/web/src/schemas/config.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { z } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> 'zod'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> nodeEnvValues</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#9ECBFF\">'development'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'test'</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'production'</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">type</span><span style=\"color:#B392F0\"> ZodType</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> typeof</span><span style=\"color:#E1E4E8\"> z;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/** For runtime env. */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getProcessEnvSchemaProps</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">z</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ZodType</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  NODE_ENV: z.</span><span style=\"color:#B392F0\">enum</span><span style=\"color:#E1E4E8\">(nodeEnvValues),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  SITE_URL: z.</span><span style=\"color:#B392F0\">url</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">regex</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#79B8FF\">[</span><span style=\"color:#F97583\">^</span><span style=\"color:#79B8FF\">/]</span><span style=\"color:#F97583\">$</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'SITE_URL should not end with a slash \"/\"'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  API_URL: z.</span><span style=\"color:#B392F0\">url</span><span style=\"color:#E1E4E8\">().</span><span style=\"color:#B392F0\">regex</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#79B8FF\">[</span><span style=\"color:#F97583\">^</span><span style=\"color:#79B8FF\">/]</span><span style=\"color:#F97583\">$</span><span style=\"color:#9ECBFF\">/</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#9ECBFF\">'API_URL should not end with a slash \"/\"'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/** For schema type. */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> processEnvSchema</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> z.</span><span style=\"color:#B392F0\">object</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">getProcessEnvSchemaProps</span><span style=\"color:#E1E4E8\">(z));</span></span></code></pre>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/app/layout.tsx\">frontend/apps/web/src/app/layout.tsx</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { PublicEnv } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/config/process-env'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">interface</span><span style=\"color:#B392F0\"> Props</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  children</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ReactNode</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> RootLayout</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#FFAB70\">children</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">html</span><span style=\"color:#B392F0\"> lang</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"en\"</span><span style=\"color:#B392F0\"> suppressHydrationWarning</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">body</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{fontInter.className}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#79B8FF\">PublicEnv</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;</span><span style=\"color:#79B8FF\">ThemeProvider</span><span style=\"color:#B392F0\"> attribute</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"class\"</span><span style=\"color:#B392F0\"> defaultTheme</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"light\"</span><span style=\"color:#B392F0\"> enableSystem</span><span style=\"color:#B392F0\"> disableTransitionOnChange</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {</span><span style=\"color:#6A737D\">/* Slot with server components */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        {children}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#79B8FF\">Toaster</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      &#x3C;/</span><span style=\"color:#79B8FF\">ThemeProvider</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">body</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">html</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> default</span><span style=\"color:#E1E4E8\"> RootLayout;</span></span></code></pre>\n<p>An example usage, for instance in <code>instrumentation.ts</code>, to log the runtime values of all environment variables for debugging purposes:</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/instrumentation.ts\">frontend/apps/web/src/instrumentation.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">/** Runs only once on server start. */</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/** Log loaded env vars. */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> register</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (process.env.</span><span style=\"color:#79B8FF\">NEXT_RUNTIME</span><span style=\"color:#F97583\"> ===</span><span style=\"color:#9ECBFF\"> 'nodejs'</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">prettyPrintObject</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#F97583\"> import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'@/utils/log'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">getPublicEnv</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#F97583\"> await</span><span style=\"color:#F97583\"> import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'@/config/process-env'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    prettyPrintObject</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">getPublicEnv</span><span style=\"color:#E1E4E8\">(), </span><span style=\"color:#9ECBFF\">'Runtime process.env'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h4 id=\"usage-for-baseurl-for-openapi-client\">Usage for <code>baseUrl</code> for OpenAPI client</h4>\n<p>This is another typical and very important spot for using the <code>API_URL</code> environment variable. What makes it tricky is that it is included and runs on both the server and in the browser, but it is defined in a single place.</p>\n<p>However, <code>alizeait/next-public-env</code> resolves this complexity very well on its own, and you can simply use <code>getPublicEnv()</code> to get the <code>API_URL</code> value while letting the package handle the rest.</p>\n<p><a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/lib/hey-api.ts\">frontend/apps/web/src/lib/hey-api.ts</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { getPublicEnv } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/config/process-env'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">/** Runtime config. Runs and imported both on server and in browser. */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> createClientConfig</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> CreateClientConfig</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">config</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> getPublicEnv</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    ...</span><span style=\"color:#E1E4E8\">config,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    baseUrl: </span><span style=\"color:#79B8FF\">API_URL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    credentials: </span><span style=\"color:#9ECBFF\">'include'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    ...</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#B392F0\">isServer</span><span style=\"color:#E1E4E8\">() </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> { fetch: serverFetch } </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> {}),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h3 id=\"legitimate-build-time-environment-variables\">Legitimate build-time environment variables</h3>\n<p>Variables that are the same for every environment can be left as <code>NEXT_PUBLIC_</code> and inlined into the bundle. They should also be versioned in Git (their <code>.env.*</code> files). Since this is the case, the best approach is to store them as TypeScript constants directly in the source, because that is what they truly are - shared constants.</p>\n<h2 id=\"build-and-deploy-reusable-docker-image\">Build and deploy reusable Docker image</h2>\n<p>Build once - deploy everywhere. Use a single image and <code>.env</code> file with no redundancy.</p>\n<h3 id=\"building\">Building</h3>\n<p>Now that we have eliminated all build-time variables by converting them to run-time environment variables, we can simply remove all build arguments and environment variables from the <code>Dockerfile</code>, Github Actions build workflow, <code>package.json</code> build scripts, etc.</p>\n<p><strong>Note:</strong> During the build phase of a Next.js app, the global scope is also executed. Therefore, if you read any environment variables, such as <code>process.env.MY_VAR_XXX</code>, your code must be able to handle a default <code>undefined</code> value without throwing exceptions, as this would break the build.</p>\n<p><strong>Tip:</strong> To access environment variables, always use <code>getPublicEnv()</code> inside components and functions. Never call <code>getPublicEnv()</code> or read <code>process.env</code> in the global scope, this way, you won’t need to handle <code>undefined</code> environment variables explicitly for the build to pass.</p>\n<p>Simply remove all build arguments and build-time environment variables from the <code>Dockerfile</code>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"dockerfile\"><code><span class=\"line\"><span style=\"color:#6A737D\"># Not needed anymore, remove all build args</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ARG</span><span style=\"color:#E1E4E8\"> ARG_NEXT_PUBLIC_SITE_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> NEXT_PUBLIC_SITE_URL=$ARG_NEXT_PUBLIC_SITE_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> echo </span><span style=\"color:#9ECBFF\">\"NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">ARG</span><span style=\"color:#E1E4E8\"> ARG_NEXT_PUBLIC_API_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">ENV</span><span style=\"color:#E1E4E8\"> NEXT_PUBLIC_API_URL=$ARG_NEXT_PUBLIC_API_URL</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">RUN</span><span style=\"color:#E1E4E8\"> echo </span><span style=\"color:#9ECBFF\">\"NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL\"</span></span></code></pre>\n<p>This is the cleaned up <code>Dockerfile</code> that I am using to build Next.js app inside the monorepo: <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/Dockerfile\">frontend/Dockerfile</a>.</p>\n<p>Also, don’t forget to clean up unused build arguments from the Github Actions workflow and <code>package.json</code> scripts for building the Docker image. You can see mine here: <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/.github/workflows/build-push-frontend.yml\">.github/workflows/build-push-frontend.yml</a>, <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/package.json\">frontend/package.json</a>.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"json\"><code><span class=\"line\"><span style=\"color:#9ECBFF\">\"scripts\"</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  \"docker:build:x86\"</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"docker buildx build -f ./Dockerfile -t nemanjamitic/full-stack-fastapi-template-nextjs-frontend --platform linux/amd64 .\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">},</span></span></code></pre>\n<h3 id=\"deployment\">Deployment</h3>\n<p>Once built, you can use that image to deploy to any environment. Naturally, you need to define and pass all runtime environment variables into the Docker container. In your <code>docker-compose.yml</code>, use the <code>env_file:</code> or <code>environment:</code> keys.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  frontend</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">nemanjamitic/full-stack-fastapi-template-nextjs-frontend:latest</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">full-stack-fastapi-template-nextjs-frontend</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    env_file</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">.env</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PORT=3000</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">SITE_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">API_URL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">https://api-full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">NODE_ENV</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">production</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ...</span></span></code></pre>\n<p>You can see <code>docker-compose.yml</code> and <code>.env</code> I am using here: <a href=\"https://github.com/nemanjam/traefik-proxy/blob/main/apps/full-stack-fastapi-template-nextjs/docker-compose.yml\">apps/full-stack-fastapi-template-nextjs/docker-compose.yml</a>, <a href=\"https://github.com/nemanjam/traefik-proxy/blob/main/apps/full-stack-fastapi-template-nextjs/.env.example\">apps/full-stack-fastapi-template-nextjs/.env.example</a></p>\n<h2 id=\"alternative-approaches\">Alternative approaches</h2>\n<p>In the <a href=\"#static-page\">Static page</a> section, I already mentioned a few notes about runtime variables and static websites. Indeed, you have two options for runtime variables:</p>\n<ol>\n<li>\n<p>Convert the website from static to dynamically rendered SSR (rendered at request time). Note that this is a significant change: from this point, your website will require a Node.js runtime, which will greatly impact your deployment options, as you can no longer use static hosting.</p>\n<p>This is overkill just for the purpose of having runtime environment variables. Use it only if your website has additional reasons to use SSR.</p>\n</li>\n<li>\n<p>Perform string replacement directly on bundle assets using <code>sed</code>, <code>envsubst</code>, etc. This is the right approach. There are other options, such as the Nginx <code>subs_filter</code> config option, but be careful with it, as it runs on each request and can waste CPU.</p>\n</li>\n</ol>\n<p>Another option to consider is using an <code>./env.js</code> file instead of the usual <code>.env</code>. You can then host it with Nginx and load it into the app using <code>&#x3C;script src=\"./env.js\" /></code>. After that, you can reference the variables with <code>window.__RUNTIME_ENV__.MY_VAR</code>.</p>\n<p>Note that this won’t work well for usage in pure HTML pages. For example, Astro omits any client-side JavaScript by default, so you would need to use an additional inline <code>&#x3C;script /></code> tag to update the HTML, e.g., <code>getElementById(\"my-id\")?.textContent = window.__RUNTIME_ENV__.MY_VAR</code>, which is less optimal than the string replacement method.</p>\n<p>Here is a quick, approximate code for illustration:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// define variables</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">window.__RUNTIME_ENV__ </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  SITE_URL: </span><span style=\"color:#9ECBFF\">\"https://my-static-website.com\"</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  PLAUSIBLE_DOMAIN: </span><span style=\"color:#9ECBFF\">'my-static-website.com'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  PLAUSIBLE_SCRIPT_URL: </span><span style=\"color:#9ECBFF\">'https://plausible.my-server.com/js/script.js'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#6A737D\"># mount and host env.js file</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">my-static-website</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">nginx:1.29.1-alpine3.22-slim</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">my-static-website</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    - </span><span style=\"color:#9ECBFF\">./website:/usr/share/nginx/html</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    - </span><span style=\"color:#9ECBFF\">./env.js:/usr/share/nginx/html/env.js</span><span style=\"color:#6A737D\"> # this</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    - </span><span style=\"color:#9ECBFF\">./nginx/nginx.conf:/etc/nginx/nginx.conf</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  # ...</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"html\"><code><span class=\"line\"><span style=\"color:#6A737D\">&#x3C;!-- Load env.js file --></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">head</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">meta</span><span style=\"color:#B392F0\"> charset</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"UTF-8\"</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">>My static website&#x3C;/</span><span style=\"color:#85E89D\">title</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">script</span><span style=\"color:#B392F0\"> src</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"./env.js\"</span><span style=\"color:#E1E4E8\">>&#x3C;/</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  &#x3C;!-- ... --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">head</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"html\"><code><span class=\"line\"><span style=\"color:#6A737D\">&#x3C;!-- example usage --></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">&#x3C;!-- example 1: assign var to text content --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">span</span><span style=\"color:#B392F0\"> id</span><span style=\"color:#E1E4E8\">=</span><span style=\"color:#9ECBFF\">\"my-element\"</span><span style=\"color:#E1E4E8\">>&#x3C;/</span><span style=\"color:#85E89D\">span</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> mySpan</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> document.</span><span style=\"color:#B392F0\">getElementById</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'my-element'</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  mySpan.textContent </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> window.__RUNTIME_ENV__.</span><span style=\"color:#79B8FF\">MY_VAR</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">&#x3C;!-- example 2: assign var to script attribute --></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> script</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> document.</span><span style=\"color:#B392F0\">createElement</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">\"script\"</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  script.defer </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> true</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  script.type </span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\"> \"text/partytown\"</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // dynamically set attributes from runtime env</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  script.dataset.domain </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> window.__RUNTIME_ENV__.</span><span style=\"color:#79B8FF\">PLAUSIBLE_DOMAIN</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  script.src </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> window.__RUNTIME_ENV__.</span><span style=\"color:#79B8FF\">PLAUSIBLE_SCRIPT_URL</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  document.head.</span><span style=\"color:#B392F0\">appendChild</span><span style=\"color:#E1E4E8\">(script);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">script</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>So, to conclude, the best approach is to use a shell script with <code>sed</code> or <code>envsubst</code> and add it to the Nginx <code>Dockerfile</code> <code>ENTRYPOINT</code> or the <code>docker-compose.yml</code> <code>command:</code>. Here is the link to the already mentioned practical tutorial again: <a href=\"https://phase.dev/blog/nextjs-public-runtime-variables/\">https://phase.dev/blog/nextjs-public-runtime-variables/</a>.</p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Next.js app repository:</strong> <a href=\"https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/main/frontend/apps/web\">https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/main/frontend/apps/web</a></li>\n<li><strong>Deployment repository:</strong> <a href=\"https://github.com/nemanjam/traefik-proxy/tree/main/apps/full-stack-fastapi-template-nextjs\">https://github.com/nemanjam/traefik-proxy/tree/main/apps/full-stack-fastapi-template-nextjs</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># 1. Next.js app repo</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/e990a3e29b7af60831851ff6f909c34df6a7f800</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> e990a3e29b7af60831851ff6f909c34df6a7f800</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># run-time vars configuration</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/config/process-env.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/schemas/config.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/app/layout.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># usages</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/instrumentation.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">frontend/apps/web/src/lib/hey-api.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># 2. Deployment repo</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># https://github.com/nemanjam/traefik-prox/tree/f3c087184e851db20e65409a6dd145767dd9bc2b</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> f3c087184e851db20e65409a6dd145767dd9bc2b</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">apps/full-stack-fastapi-template-nextjs/docker-compose.yml</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">apps/full-stack-fastapi-template-nextjs/.env.example</span></span></code></pre>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>If you go by inertia and mix and scatter run-time and build-time variables around the source code, build, and deployment configuration, you will end up with development and production environments that are difficult to manage, hard to debug and replicate bugs, have an unreliable deployment process, constantly require troubleshooting for missing or invalid environment variables, and result in redundant Docker images, among other issues.</p>\n<p>So, take a proactive approach: understand properly and identify the variables you are dealing with. One way to do this is to leverage the power and convenience of run-time environment variables.</p>\n<p>What approach do you use to manage environment variables in Next.js apps? Feel free to share your experiences and opinions in the comments.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>How to use environment variables in Next.js, Next.js docs guide <a href=\"https://nextjs.org/docs/app/guides/environment-variables#runtime-environment-variables\">https://nextjs.org/docs/app/guides/environment-variables#runtime-environment-variables</a></li>\n<li>How to self-host your Next.js application, Next.js docs guide <a href=\"https://nextjs.org/docs/app/guides/self-hosting#environment-variables\">https://nextjs.org/docs/app/guides/self-hosting#environment-variables</a></li>\n<li>Better support for runtime environment variables #44628, Github discussion <a href=\"https://github.com/vercel/next.js/discussions/44628\">https://github.com/vercel/next.js/discussions/44628</a></li>\n<li>Docker image with NEXT_PUBLIC_ env variables #17641, Github discussion <a href=\"https://github.com/vercel/next.js/discussions/17641\">https://github.com/vercel/next.js/discussions/17641</a></li>\n<li>Not possible to use different configurations in staging + production #22243, Github discussion <a href=\"https://github.com/vercel/next.js/discussions/22243\">https://github.com/vercel/next.js/discussions/22243</a></li>\n<li>Runtime variables for static website, tutorial <a href=\"https://phase.dev/blog/nextjs-public-runtime-variables/\">https://phase.dev/blog/nextjs-public-runtime-variables/</a></li>\n<li>Runtime Environment Variables in Next.js, concise overview <a href=\"https://dt.in.th/NextRuntimeEnv\">https://dt.in.th/NextRuntimeEnv</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-12-13-nextjs-runtime-environment-variables/",
            "title": "Runtime environment variables in Next.js - build reusable Docker images",
            "summary": "Learn how to configure Next.js with runtime environment variables and build Docker images you can reuse across multiple environments.\n",
            "date_modified": "2025-12-13T00:00:00.000Z",
            "date_published": "2025-12-13T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-07-31-maze-solver/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;</p>\n<p>import MazeSolverClassDiagramImage from ’../../../../content/post/2025/07-31-maze-solver/_images/maze-solver-class-diagram.png’;\nimport YarnDevImage from ’../../../../content/post/2025/07-31-maze-solver/_images/yarn-dev.png’;\nimport YarnTestVerboseImage from ’../../../../content/post/2025/07-31-maze-solver/_images/yarn-test-verbose.png’;\nimport YarnCoverageImage from ’../../../../content/post/2025/07-31-maze-solver/_images/yarn-coverage.png’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>Pathfinding is a fundamental topic in computer science, with applications in fields like navigation, AI/ML, network routing, and many others. In this article, we compare four core pathfinding algorithms: breadth-first search (BFS), depth-first search (DFS), Dijkstra’s algorithm, and A* (A star) through a practical maze-solving example. We don’t just explain them in theory, we built a demo app where you can tweak maze inputs or edit the algorithm code and instantly see how it affects the output and efficiency.</p>\n<p>One of the key takeaways is how a tiny change, just a single line in the code can drastically alter an algorithm’s behavior. This highlights how critical implementation details are, even when the overall structure looks the same.</p>\n<h2 id=\"problem-overview\">Problem overview</h2>\n<p>Paths in a maze form a tree structure or a graph if the maze contains cycles. That’s why tree and graph traversal algorithms can be used for finding paths and the shortest path in a maze.</p>\n<p>All 4 algorithms differ in just a few lines of code, but their behavior differs dramatically.</p>\n<h2 id=\"app-architecture\">App architecture</h2>\n<p>We create a pragmatic, simplified OOP model of the maze and its behavior as a tradeoff, favoring clarity and concise instantiation of maze objects in tests.</p>\n<h3 id=\"maze-representation\">Maze representation</h3>\n<p>A maze is represented as a binary matrix where <code>0</code> stands for a free space, <code>1</code> for a boundary, and <code>*</code> for a path. It also has start and end points. In the sense of a weighted graph <code>0</code> cell has zero weight and cell <code>1</code> has infinite weight.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> class</span><span style=\"color:#B392F0\"> Maze</span><span style=\"color:#F97583\"> implements</span><span style=\"color:#B392F0\"> IMaze</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  private</span><span style=\"color:#FFAB70\"> board</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">[][];</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  private</span><span style=\"color:#FFAB70\"> start</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  private</span><span style=\"color:#FFAB70\"> end</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> interface</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  readonly</span><span style=\"color:#FFAB70\"> x</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  readonly</span><span style=\"color:#FFAB70\"> y</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// example maze</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> testMaze</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">[][] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  [</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  [</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  [</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  [</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  [</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> start</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> { x: </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, y: </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\"> };</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> end</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> { x: </span><span style=\"color:#79B8FF\">4</span><span style=\"color:#E1E4E8\">, y: </span><span style=\"color:#79B8FF\">4</span><span style=\"color:#E1E4E8\"> };</span></span></code></pre>\n<h3 id=\"class-structure\">Class structure</h3>\n<p>We use polymorphism and a simplified Factory pattern. <code>MazeSolver</code> is an abstract class that declares the <code>findPath()</code> method, which is implemented in each derived concrete solver class.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">/**</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"> * Abstract base class for maze solving algorithms.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"> * Implements common functionality for maze solvers.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"> */</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> abstract</span><span style=\"color:#F97583\"> class</span><span style=\"color:#B392F0\"> MazeSolver</span><span style=\"color:#F97583\"> implements</span><span style=\"color:#B392F0\"> IMazeSolver</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  protected</span><span style=\"color:#FFAB70\"> maze</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> IMaze</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  protected</span><span style=\"color:#F97583\"> abstract</span><span style=\"color:#B392F0\"> findPath</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\">[] </span><span style=\"color:#F97583\">|</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>We use encapsulation and coding towards interface, separating interfaces from implementations by exposing only the public class methods through the interfaces.</p>\n<p>Maze interface:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> interface</span><span style=\"color:#B392F0\"> IMaze</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  getBoard</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">[][];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  getStart</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  formatPath</span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">path</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ReadonlyArray</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Coordinate</span><span style=\"color:#E1E4E8\">>) </span><span style=\"color:#F97583\">=></span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Maze implementation:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> class</span><span style=\"color:#B392F0\"> Maze</span><span style=\"color:#F97583\"> implements</span><span style=\"color:#B392F0\"> IMaze</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  public</span><span style=\"color:#B392F0\"> getBoard</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">[][] { </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  public</span><span style=\"color:#B392F0\"> getStart</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  public</span><span style=\"color:#B392F0\"> formatPath</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#FFAB70\">path</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ReadonlyArray</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Coordinate</span><span style=\"color:#E1E4E8\">>)</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<p>Maze usage:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> _maze2</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> IMaze</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> Maze.</span><span style=\"color:#B392F0\">create</span><span style=\"color:#E1E4E8\">(testMaze, start, end);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span></code></pre>\n<p><strong>Class diagram:</strong></p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={MazeSolverClassDiagramImage} alt=“Maze solver class diagram” /></p>\n<h2 id=\"running-the-app\">Running the app</h2>\n<p>We install dependencies, run the app, and run the tests as usual, like any other TypeScript app.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># install dependencies</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> install</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># enable or disable logging in src/config.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># run the app in dev mode</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> dev</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># logging is disabled for tests by default</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># run tests</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> test</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># run tests in verbose mode</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> test-verbose</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># generate coverage report</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> coverage</span></span></code></pre>\n<p>We can see that different algorithms require a different number of steps for the same input maze. Example output for a given maze input:</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_XS} src={YarnDevImage} alt=“App dev mode terminal output” /></p>\n<p>We can run tests that ensure for each algorithm:</p>\n<ol>\n<li>finds existing path</li>\n<li>doesn’t find a false non existent path</li>\n<li>finds the shortest path.</li>\n</ol>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_XS} src={YarnTestVerboseImage} alt=“Run tests in verbose mode” /></p>\n<p>And calculate the code coverage:</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_SM} src={YarnCoverageImage} alt=“Tests coverage table” /></p>\n<h2 id=\"algorithms-analysis-and-discussion\">Algorithms analysis and discussion</h2>\n<p>Now for the most important and interesting part: let’s analyze the algorithm’s code and explain how it affects their behavior and efficiency.</p>\n<h3 id=\"unweighted-graphs\">Unweighted graphs</h3>\n<p>BFS and DFS are basic traversal algorithms that ignore the weights of the edges, so they are applicable only to unweighted graphs.</p>\n<p>The actual code for BFS and DFS differs by only a single line, but they exhibit completely opposite behavior. BFS uses a queue (FIFO), while DFS uses a stack (LIFO), and this has a fundamental impact on how the next node candidate for the path is selected.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// BFS</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">coord</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">path</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> queue.</span><span style=\"color:#B392F0\">shift</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// DFS</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">coord</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">path</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> stack.</span><span style=\"color:#B392F0\">pop</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>This is the array of coordinates that represents possible directions for movement. This array is iterated over in the algorithm’s inner loop.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> directions</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Direction</span><span style=\"color:#E1E4E8\">[] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> [</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  { x: </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, y: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\"> }, </span><span style=\"color:#6A737D\">// up</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  { x: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, y: </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\"> }, </span><span style=\"color:#6A737D\">// right</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  { x: </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, y: </span><span style=\"color:#F97583\">-</span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\"> }, </span><span style=\"color:#6A737D\">// down</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  { x: </span><span style=\"color:#F97583\">-</span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">, y: </span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\"> }, </span><span style=\"color:#6A737D\">// left</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">];</span></span></code></pre>\n<p>Here is the complete BFS implementation (since all 4 algorithms share most of the same base) so we can have a better idea of what we are working with:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> class</span><span style=\"color:#B392F0\"> MazeSolverBFS</span><span style=\"color:#F97583\"> extends</span><span style=\"color:#B392F0\"> MazeSolver</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /**</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">   * Implements the Breadth-First Search (BFS) algorithm to find a path from the start to the end of the maze.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">   */</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  protected</span><span style=\"color:#B392F0\"> findPath</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#E1E4E8\">[] </span><span style=\"color:#F97583\">|</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> start</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> this</span><span style=\"color:#E1E4E8\">.maze.</span><span style=\"color:#B392F0\">getStart</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Initialize the BFS queue with the start position.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> queue</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> BFSQueueElement</span><span style=\"color:#E1E4E8\">[] </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> [{ coord: start, path: [start] }];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Keep track of visited coordinates (as strings).</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> visited</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> Set</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">string</span><span style=\"color:#E1E4E8\">>();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    visited.</span><span style=\"color:#B392F0\">add</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">`${</span><span style=\"color:#E1E4E8\">start</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">x</span><span style=\"color:#9ECBFF\">},${</span><span style=\"color:#E1E4E8\">start</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">y</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">    while</span><span style=\"color:#E1E4E8\"> (queue.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#F97583\"> ></span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#E1E4E8\">) {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // Count iterations.</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      this</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">incrementStep</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // The most important line. </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // FIFO - Takes the oldest element in the queue.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">coord</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">path</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> queue.</span><span style=\"color:#B392F0\">shift</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // Check if end and exit.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#79B8FF\">this</span><span style=\"color:#E1E4E8\">.maze.</span><span style=\"color:#B392F0\">isEnd</span><span style=\"color:#E1E4E8\">(coord)) {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        return</span><span style=\"color:#E1E4E8\"> path;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // Print the current state of the maze.</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">      this</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">printBoard</span><span style=\"color:#E1E4E8\">(coord, visited, path);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      // Always loops 4 times.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      for</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> direction</span><span style=\"color:#F97583\"> of</span><span style=\"color:#E1E4E8\"> directions) {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // Calculate the next coordinate by applying the direction.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        const</span><span style=\"color:#79B8FF\"> nextCoord</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Coordinate</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          x: coord.x </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> direction.x,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          y: coord.y </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> direction.y,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        };</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // Create a key for nextCoord (to check for uniqueness in the visited set).</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        const</span><span style=\"color:#79B8FF\"> coordKey</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> `${</span><span style=\"color:#E1E4E8\">nextCoord</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">x</span><span style=\"color:#9ECBFF\">},${</span><span style=\"color:#E1E4E8\">nextCoord</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">y</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">        // If nextCoord is not visited, is within bounds, and is walkable, add it to the potential path.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">        if</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">          !</span><span style=\"color:#E1E4E8\">visited.</span><span style=\"color:#B392F0\">has</span><span style=\"color:#E1E4E8\">(coordKey) </span><span style=\"color:#F97583\">&#x26;&#x26;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">          this</span><span style=\"color:#E1E4E8\">.maze.</span><span style=\"color:#B392F0\">isWithinBounds</span><span style=\"color:#E1E4E8\">(nextCoord) </span><span style=\"color:#F97583\">&#x26;&#x26;</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">          this</span><span style=\"color:#E1E4E8\">.maze.</span><span style=\"color:#B392F0\">isWalkable</span><span style=\"color:#E1E4E8\">(nextCoord)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        ) {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          visited.</span><span style=\"color:#B392F0\">add</span><span style=\"color:#E1E4E8\">(coordKey);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          queue.</span><span style=\"color:#B392F0\">push</span><span style=\"color:#E1E4E8\">({ coord: nextCoord, path: [</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">path, nextCoord] });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Return null if no path to the end is found.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span></code></pre>\n<h3 id=\"bfs\">BFS</h3>\n<p>Since BFS uses a queue, it respects this structure and attempts to change direction in every iteration of the outer loop. Without obstacles and boundaries, this causes the algorithm to thoroughly inspect nodes closer to the starting node before moving further away. That’s why BFS can be inefficient for large trees and graphs where the end node is very distant from the starting node.</p>\n<h3 id=\"dfs\">DFS</h3>\n<p>In contrast, DFS also respects the initial order in the directions array but prioritizes the earlier elements. So, in the example above, it will always attempt to apply the <code>up</code> direction first before exploring other directions. Without obstacles and boundaries, this causes the algorithm to inspect distant nodes in a straight line. DFS can be efficient for finding a distant end node but can also be very inefficient for finding a nearby node if it happens to be in a different direction.</p>\n<h3 id=\"weighted-graphs\">Weighted graphs</h3>\n<p>Not all graphs have edges with uniform weights. In such cases, we must use algorithms that are aware of weights (the cost between two nodes), such as Dijkstra and A*.</p>\n<h3 id=\"dijkstra\">Dijkstra</h3>\n<p>Dijkstra’s algorithm is aware of the cost between two nodes (edge weight) and takes it into account when selecting the next node. It uses a priority queue to keep track of the cost history.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// Take the first element from the priority queue.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Choose the node that ads minimal cost.</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">queue.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> a.cost </span><span style=\"color:#F97583\">-</span><span style=\"color:#E1E4E8\"> b.cost);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">coord</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">path</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">cost</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> queue.</span><span style=\"color:#B392F0\">shift</span><span style=\"color:#E1E4E8\">()</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// Test how much cost every new node ads to the path before adding it to the queue.</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> nextCost</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> cost </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> this</span><span style=\"color:#E1E4E8\">.maze.</span><span style=\"color:#B392F0\">getCost</span><span style=\"color:#E1E4E8\">(nextCoord);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">if</span><span style=\"color:#E1E4E8\">( </span><span style=\"color:#F97583\">...</span><span style=\"color:#F97583\"> &#x26;&#x26;</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">costMap.</span><span style=\"color:#B392F0\">has</span><span style=\"color:#E1E4E8\">(coordKey) </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> nextCost </span><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\"> costMap.</span><span style=\"color:#B392F0\">get</span><span style=\"color:#E1E4E8\">(coordKey)</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>Dijkstra keeps a history of the cost of the current path and when selecting the next node chooses the node that adds the minimal cost. If there are cycles it may access the same node from multiple paths and will choose the one with the minimal weight (shortest path). In graphs with constant edge weights it reduces to BFS. This can be observed in the screenshot above, where both BFS and Dijkstra take an equal number of steps because the maze has uniform weights of 1 and Infinity.</p>\n<h3 id=\"a\">A*</h3>\n<p>A* is the same as Dijkstra but besides keeping the history, it uses a heuristic function to predict the future - the direction in which the end node could be.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">protected </span><span style=\"color:#B392F0\">heuristic</span><span style=\"color:#E1E4E8\">(a: Coordinate, b: Coordinate): number {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Manhattan distance as the heuristic.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Can only move horizontally or vertically, not diagonally. In \"rectangles\".</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    return</span><span style=\"color:#E1E4E8\"> Math.</span><span style=\"color:#B392F0\">abs</span><span style=\"color:#E1E4E8\">(a.x </span><span style=\"color:#F97583\">-</span><span style=\"color:#E1E4E8\"> b.x) </span><span style=\"color:#F97583\">+</span><span style=\"color:#E1E4E8\"> Math.</span><span style=\"color:#B392F0\">abs</span><span style=\"color:#E1E4E8\">(a.y </span><span style=\"color:#F97583\">-</span><span style=\"color:#E1E4E8\"> b.y);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">openSet.</span><span style=\"color:#B392F0\">sort</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  (</span><span style=\"color:#FFAB70\">a</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">b</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // The most important line. The only difference from Dijkstra.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // Cost = history + Manhattan distance from the end node.</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // prettier-ignore</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    (a.cost </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> this</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">heuristic</span><span style=\"color:#E1E4E8\">(a.coord, end)) </span><span style=\"color:#F97583\">-</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    (b.cost </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> this</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">heuristic</span><span style=\"color:#E1E4E8\">(b.coord, end))</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span></code></pre>\n<p>If the heuristic function is well chosen it will make A* more efficient than the before mentioned algorithms. Consequently, if the heuristic function is poorly chosen it will degrade the algorithm efficiency.</p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Maze solver:</strong> <a href=\"https://github.com/nemanjam/maze-solver\">https://github.com/nemanjam/maze-solver</a></li>\n</ul>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>In this example, we can see how algorithm analysis and design is a very sensitive and subtle discipline that leaves no room for low focus or a lack of understanding of the domain. Although BFS, DFS, Dijkstra, and A* share most of their implementation, even a subtle change in the code can lead to a dramatic change in behavior.</p>\n<p>In the demo app, you can tweak the predefined mazes in the <code>tests/fixtures/*.txt</code> files and make your own observations. You can also check the resources and interactive playground listed in the <a href=\"#references\">References</a> section.</p>\n<p>Have you experimented with maze-solving and pathfinding algorithms before? Let me know in the comments.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Some visualized algorithms behavior <a href=\"https://www.youtube.com/watch?v=GC-nBgi9r0U\">https://www.youtube.com/watch?v=GC-nBgi9r0U</a></li>\n<li>BFS vs DFS, basic overview and implementation <a href=\"https://www.geeksforgeeks.org/difference-between-bfs-and-dfs/\">https://www.geeksforgeeks.org/difference-between-bfs-and-dfs/</a></li>\n<li>BFS vs Dijkstra for unweighted and weighted graphs <a href=\"https://www.baeldung.com/cs/graph-algorithms-bfs-dijkstra\">https://www.baeldung.com/cs/graph-algorithms-bfs-dijkstra</a></li>\n<li>BFS vs Dijkstra similarities <a href=\"https://stackoverflow.com/a/52676408/4383275\">https://stackoverflow.com/a/52676408/4383275</a></li>\n<li>Visual playgrounds <a href=\"https://visualmazesolver.vercel.app/\">https://visualmazesolver.vercel.app/</a>, <a href=\"http://qiao.github.io/PathFinding.js/visual/\">http://qiao.github.io/PathFinding.js/visual/</a></li>\n<li>Starter project, Typescript, Jest <a href=\"https://github.com/julianmateu/hello-ts\">https://github.com/julianmateu/hello-ts</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-07-31-maze-solver/",
            "title": "Comparing BFS, DFS, Dijkstra, and A* algorithms on a practical maze solver example",
            "summary": "Understanding the strengths and trade-offs of core pathfinding algorithms through a practical maze example. Demo app included.\n",
            "date_modified": "2025-07-31T00:00:00.000Z",
            "date_published": "2025-07-31T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-05-29-traefik-load-balancer/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;</p>\n<p>import TraefikLoadBalancerArchitectureImage from ’../../../../content/post/2025/05-29-traefik-load-balancer/_images/traefik-load-balancer-architecture.png’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>This article is a continuation of <a href=\"/blog/2025-04-29-rathole-traefik-home-server\">Expose home server with Rathole tunnel and Traefik</a> article, which explains how to permanently host websites from home by bypassing CGNAT. That setup works well for exposing a single home server (like a Raspberry Pi, server PC, or virtual machine), but it has a limitation: it requires one VPS (or at least one public network interface) per home server. This is because the Rathole server exclusively uses ports <code>80</code> and <code>443</code>.</p>\n<p>But it doesn’t have to be like this. We can reuse a single Rathole server for many tunnels and home servers, we just need a tool to load balance their traffic, as long as our VPS’s network interface provides enough bandwidth for our websites and services.</p>\n<p>This article explains how to achieve that using Traefik HTTP and TCP routers.</p>\n<h2 id=\"prerequisites\">Prerequisites</h2>\n<ul>\n<li>A working Rathole tunnel setup from the previous article (including a VPS and a domain name)</li>\n<li>More than one home server (Raspberry Pi, server PC, virtual machine, or LXC container)</li>\n</ul>\n<h2 id=\"architecture-overview\">Architecture overview</h2>\n<h3 id=\"the-problem\">The problem</h3>\n<p>The main problem here is that we can’t bind more than one port to ports <code>80</code> and <code>443</code>, respectively. Only one service can listen on a given port at the same time. So something like this doesn’t exist:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  rathole</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rapiz1/rathole:v0.5.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">--server /config/rathole.server.toml</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # host:container</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">2333:2333</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">80:5080,5081</span><span style=\"color:#6A737D\"> # non existent syntax, can't bind two ports to a single port</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">443:5443,5444</span><span style=\"color:#6A737D\"> # same</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./rathole.server.toml:/config/rathole.server.toml:ro</span></span></code></pre>\n<p>Neither the operating system nor Docker provides load balancing functionality out of the box, we need to handle it ourselves.</p>\n<h3 id=\"the-solution\">The solution</h3>\n<p>We need to introduce a tool for load balancing traffic between tunnels. We will use Traefik, since we already use it with the Rathole client.</p>\n<p>For each home server, we need 2 tunnels: one for HTTP and another for HTTPS traffic:</p>\n<ol>\n<li>The tunnel for HTTP traffic will use the Traefik HTTP router as usual.</li>\n<li>The tunnel for HTTPS traffic is a bit more interesting and challenging. For it, we will use the Traefik TCP router running in passthrough mode, since we don’t want to terminate HTTPS traffic on the VPS. Instead, we want to delegate certificate resolution to the existing Traefik instance running on the client side to preserve the current setup and architecture.</li>\n</ol>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={TraefikLoadBalancerArchitectureImage} alt=“Traefik load balancer architecture diagram” /></p>\n<p><strong>Reminder:</strong></p>\n<p>I already wrote about the advantage of resolving SSL certificates locally on the home server in the <a href=\"/blog/2025-04-29-rathole-traefik-home-server#architecture-overview\">Architecture overview</a> section of the previous article, but here is a quick recap:</p>\n<ol>\n<li>The home server contains its entire configuration</li>\n<li>The home server is tunnel-agnostic and reusable</li>\n<li>No coupling between the tunnel server and client, no need to maintain state or version</li>\n<li>Decoupled debugging</li>\n<li>Improved security, an additional encryption layer further down the tunnel</li>\n</ol>\n<h2 id=\"traefik-load-balancer-and-rathole-server\">Traefik load balancer and Rathole server</h2>\n<p>Since we passthrough encrypted HTTPS traffic, Traefik can’t read the subdomain from an HTTP request as usual. Instead, we will run the Traefik router in TCP mode, using the <a href=\"https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1\">HostSNIRegexp</a> matcher. This will run the router on layer 4 (TCP) instead of the usual layer 7 (HTTP).</p>\n<p>For more in-depth info on how this works, you can read here: <a href=\"https://en.wikipedia.org/wiki/Server_Name_Indication\">Server Name Indication (SNI)</a>.</p>\n<p>Now that we understand the principle, we can get to the practical implementation.</p>\n<h3 id=\"traefik-http-and-tcp-routers\">Traefik HTTP and TCP routers</h3>\n<p>Below is the complete <code>docker-compose.yml</code> that defines the Traefik TCP router and the Rathole server with 2 HTTP/HTTPS tunnel pairs for 2 home servers: <code>pi</code> (OrangePi) and <code>local</code> (MiniPC), in my case.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'3.8'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  traefik</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">traefik:v2.9.8</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">traefik</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--providers.docker=true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--entrypoints.web.address=:80</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--entrypoints.websecure.address=:443</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--entrypoints.traefik.address=:8080</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--api.dashboard=true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--api.insecure=false</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--log.level=DEBUG</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">--accesslog=true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">80:80</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">443:443</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">8080:8080</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">/var/run/docker.sock:/var/run/docker.sock:ro</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Enable the dashboard at http://traefik.amd2.nemanjamitic.com</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # http for simplicity, no acme.json file</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.enable=true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.traefik.rule=Host(`traefik.amd2.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.traefik.entrypoints=web</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.traefik.service=api@internal</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.traefik.middlewares=auth</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_AUTH}'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  rathole</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rapiz1/rathole:v0.5.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">--server /config/rathole.server.toml</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">2333:2333</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./rathole.server.toml:/config/rathole.server.toml:ro</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      ### HTTP port 80 - HTTP routers ###</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # pi.nemanjamitic.com, www.pi.nemanjamitic.com, *.pi.nemanjamitic.com, www.*.pi.nemanjamitic.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Route *.pi.nemanjamitic.com -> 5080</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.rathole-pi.rule=HostRegexp(`pi.${SITE_HOSTNAME}`, `www.pi.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.rathole-pi.entrypoints=web</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.rathole-pi.service=rathole-pi</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.services.rathole-pi.loadbalancer.server.port=5080</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Route *.local.nemanjamitic.com -> 5081</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.rathole-local.rule=HostRegexp(`local.${SITE_HOSTNAME}`, `www.local.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.rathole-local.entrypoints=web</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.routers.rathole-local.service=rathole-local</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.http.services.rathole-local.loadbalancer.server.port=5081</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      ### HTTPS port 443 with TLS passthrough - TCP routers ###</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Route *.pi.nemanjamitic.com -> 5443</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.tcp.routers.rathole-pi-secure.rule=HostSNIRegexp(`pi.${SITE_HOSTNAME}`, `www.pi.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.routers.rathole-pi-secure.entrypoints=websecure</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.routers.rathole-pi-secure.tls.passthrough=true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.routers.rathole-pi-secure.service=rathole-pi-secure</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.services.rathole-pi-secure.loadbalancer.server.port=5443</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # Route *.local.nemanjamitic.com -> 5444</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.tcp.routers.rathole-local-secure.rule=HostSNIRegexp(`local.${SITE_HOSTNAME}`, `www.local.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.routers.rathole-local-secure.entrypoints=websecure</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.routers.rathole-local-secure.tls.passthrough=true</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.routers.rathole-local-secure.service=rathole-local-secure</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">traefik.tcp.services.rathole-local-secure.loadbalancer.server.port=5444</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  proxy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    external</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<p>Let’s start with the most important part: the <code>labels</code> on the <code>rathole</code> container that define load balancing on the two tunnels.</p>\n<p>First, we define two HTTP routers using the <code>HostRegexp()</code> matcher. It takes HTTP traffic from the entrypoint on port <code>80</code> and load balances it between two tunnels on ports <code>5080</code> and <code>5081</code>.</p>\n<p>The second pair of labels defines a TCP router that takes traffic from the HTTPS entrypoint on port <code>443</code>, passes it through without decrypting, and load balances it between tunnels on ports <code>5443</code> and <code>5444</code>. Note that with the <code>HostSNIRegexp()</code> matcher, you can’t include escaped dots (<code>.</code>) in the regex, you must repeat the entire domain sequence to handle the <code>www</code> variant of the domain.</p>\n<p>Also note that we use separate regex variants to match the root subdomain explicitly, e.g. <code>pi.nemanjamitic.com</code> and <code>www.pi.nemanjamitic.com</code> for both HTTP and TCP routers.</p>\n<p>That’s it, this is the main load balancing logic definition.</p>\n<p><strong>Note:</strong> Because we use <code>HostRegexp()</code> and <code>HostSNIRegexp()</code> on the server, you will need to use <code>Host()</code> and <code>HostSNI()</code> matchers <strong>for the Traefik running on the client side of the tunnel</strong>, or you will get <code>404</code> errors without additional configuration. Regex matchers on both the server and client sides seem to be too loose.</p>\n<h3 id=\"rathole-server-config\">Rathole server config</h3>\n<p>Now it’s just left to write the config for the Rathole server that defines 2×2 tunnels. Just make sure to use <strong>a different token and port</strong> for each tunnel.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"toml\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:2333\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">type = </span><span style=\"color:#9ECBFF\">\"noise\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">noise</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_private_key = </span><span style=\"color:#9ECBFF\">\"private_key\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># separated based on token, also can NOT use same ports</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># pi</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">pi-traefik-http</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:5080\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">pi-traefik-https</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:5443\"</span><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># local</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">local-traefik-http</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_2\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:5081\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">local-traefik-https</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_2\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:5444\"</span></span></code></pre>\n<p><strong>Reminder:</strong> You just need to open port <code>2333</code> in the VPS firewall for the Rathole control channel and not for the ports <code>5080</code>, <code>5081</code>, <code>5443</code>, or <code>5444</code>, because they are used by Rathole internally.</p>\n<h3 id=\"traefik-dashboard\">Traefik dashboard</h3>\n<p>Additionally, for the sake of debugging, we expose the Traefik dashboard using <code>labels</code> on the <code>traefik</code> container. To simplify the configuration and avoid handling the <code>acme.json</code> file, we expose it using HTTP.</p>\n<p><strong>Warning:</strong> When setting the dashboard hashed password via the <code>TRAEFIK_AUTH</code> environment variable, make sure to escape the <code>$</code> characters properly or authentication will break. To do that, you need to use both double quotes <code>\"...\"</code> and the escape slash ‘<code>\\</code>’, as shown in the example below:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># install apache2-utils</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> apt</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#9ECBFF\"> apache2-utils</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># hash the password</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">htpasswd</span><span style=\"color:#79B8FF\"> -nb</span><span style=\"color:#9ECBFF\"> admin</span><span style=\"color:#9ECBFF\"> yourpassword</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># use BOTH \"...\" and \\$ to escape $ properly</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># this will work correctly</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">TRAEFIK_AUTH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"admin:</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#9ECBFF\">asd1</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#9ECBFF\">E3lsdAo</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#9ECBFF\">3Mertp57JJ4LVU.HRR0\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># this will break</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">TRAEFIK_AUTH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"admin:</span><span style=\"color:#E1E4E8\">$asd1$E3lsdAo$3Mertp57JJ4LVU</span><span style=\"color:#9ECBFF\">.HRR0\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># this will also break</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">TRAEFIK_AUTH</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">admin:</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#9ECBFF\">asd1</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#9ECBFF\">E3lsdAo</span><span style=\"color:#79B8FF\">\\$</span><span style=\"color:#9ECBFF\">3Mertp57JJ4LVU.HRR0</span></span></code></pre>\n<h2 id=\"rathole-client\">Rathole client</h2>\n<p>The client part of the tunnel is almost the same as for a single home server. The only thing to keep in mind is to bind the specific client only to the tunnels that are meant for it, and not to all tunnels. Kind of obvious and self-explanatory, but just in case, let’s be very clear and explicit.</p>\n<p>Here, we define the <code>rathole.client.toml</code> Rathole client config to bind the <code>pi</code> home server to its HTTP <code>pi-traefik-http</code> and HTTPS <code>pi-traefik-https</code> tunnels.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"toml\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">remote_addr = </span><span style=\"color:#9ECBFF\">\"123.123.123.123:2333\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">type = </span><span style=\"color:#9ECBFF\">\"noise\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">noise</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">remote_public_key = </span><span style=\"color:#9ECBFF\">\"public_key\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># single client per tunnels pair</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># pi</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">pi-traefik-http</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_addr = </span><span style=\"color:#9ECBFF\">\"traefik:80\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">pi-traefik-https</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_addr = </span><span style=\"color:#9ECBFF\">\"traefik:443\"</span><span style=\"color:#E1E4E8\">  </span></span></code></pre>\n<p>Similarly, here we define the <code>rathole.client.toml</code> config to bind the <code>local</code> home server to it’s HTTP <code>local-traefik-http</code> and HTTPS <code>local-traefik-https</code> tunnels.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"toml\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">remote_addr = </span><span style=\"color:#9ECBFF\">\"123.123.123.123:2333\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">type = </span><span style=\"color:#9ECBFF\">\"noise\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">noise</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">remote_public_key = </span><span style=\"color:#9ECBFF\">\"public_key\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># single client per tunnels pair</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># local</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">local-traefik-http</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_2\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_addr = </span><span style=\"color:#9ECBFF\">\"traefik:80\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">local-traefik-https</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_2\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_addr = </span><span style=\"color:#9ECBFF\">\"traefik:443\"</span></span></code></pre>\n<p><code>docker-compose.yml</code> for the Rathole client and Traefik is exactly the same as it was for a single home server. I am repeating it here just for the sake of completeness.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'3.8'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  rathole</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rapiz1/rathole:v0.5.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">--client /config/rathole.client.toml</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./rathole.client.toml:/config/rathole.client.toml:ro</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  traefik</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'traefik:v2.9.8'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">traefik</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    depends_on</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # moved from static conf to pass email as env var</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'--certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_LETSENCRYPT_EMAIL}'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    security_opt</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">no-new-privileges:true</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # rathole will pass traffic through proxy network directly on 80 and 443</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # defined in rathole.client.toml</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">TRAEFIK_AUTH=${TRAEFIK_AUTH}</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">/etc/localtime:/etc/localtime:ro</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">/var/run/docker.sock:/var/run/docker.sock:ro</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./traefik-data/traefik.yml:/traefik.yml:ro</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./traefik-data/acme.json:/acme.json</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./traefik-data/configurations:/configurations</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.enable=true'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.docker.network=proxy'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.traefik-secure.entrypoints=websecure'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.traefik-secure.rule=Host(`traefik.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.traefik-secure.middlewares=user-auth@file'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.traefik-secure.service=api@internal'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  proxy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    external</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Traefik load balancer and Rathole server:</strong> <a href=\"https://github.com/nemanjam/rathole-server\">https://github.com/nemanjam/rathole-server</a></li>\n<li><strong>Rathole client and local Traefik:</strong> <a href=\"https://github.com/nemanjam/traefik-proxy/tree/main/core\">https://github.com/nemanjam/traefik-proxy/tree/main/core</a></li>\n</ul>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>You can use this setup to expose as many home servers as you want, in a cost-effective and practical way, as long as your VPS has enough network bandwidth to support their traffic. It can bring your homelab to another level.</p>\n<p>What tool and method did you use to expose your home servers to the internet? Do you like this approach, are you willing to give it a try? Let me know in the comments.</p>\n<p>Happy self-hosting.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Traefik <code>v2.9</code> <code>HostRegexp</code> reference: <a href=\"https://doc.traefik.io/traefik/v2.9/routing/routers/#rule\">https://doc.traefik.io/traefik/v2.9/routing/routers/#rule</a></li>\n<li>Traefik <code>v2.9</code> <code>HostSNIRegexp</code> reference: <a href=\"https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1\">https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1</a></li>\n<li>TLS Server Name Indication (SNI), Wikipedia <a href=\"https://en.wikipedia.org/wiki/Server_Name_Indication\">https://en.wikipedia.org/wiki/Server_Name_Indication</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-05-29-traefik-load-balancer/",
            "title": "Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers",
            "summary": "Expose multiple home servers using a single Rathole server.\n",
            "date_modified": "2025-05-29T00:00:00.000Z",
            "date_published": "2025-05-29T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-04-29-rathole-traefik-home-server/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;</p>\n<p>import RatholeTraefikArchitectureImage from ’../../../../content/post/2025/04-29-rathole-traefik-home-server/_images/rathole-traefik-architecture-16-9.png’;\nimport FirewallImage from ’../../../../content/post/2025/04-29-rathole-traefik-home-server/_images/firewall.png’;\nimport HomeServerContainersImage from ’../../../../content/post/2025/04-29-rathole-traefik-home-server/_images/home-server-containers.png’;\nimport OrangePiGifImage from ’../../../../content/post/2025/04-29-rathole-traefik-home-server/_images/orange-pi.gif’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>In the previous article, I wrote about a temporary SSH tunneling technique to bypass CGNAT. This method is not suitable for exposing permanent services, at least not without <code>autossh</code> manager. Proper tools for this are <a href=\"https://github.com/rapiz1/rathole\">rapiz1/rathole</a> or <a href=\"https://github.com/fatedier/frp\">fatedier/frp</a>. I chose Rathole since it’s written in Rust and offers better performance and benchmarks.</p>\n<h2 id=\"prerequisites\">Prerequisites</h2>\n<ul>\n<li>A VPS server with a public IP and Docker, ideally small, you can’t use ports <code>80</code> and <code>443</code> for any other services aside from Rathole</li>\n<li>A home server</li>\n<li>A domain name</li>\n</ul>\n<h2 id=\"architecture-overview\">Architecture overview</h2>\n<p>We will use Rathole for an encrypted tunnel between the VPS and the local network. We will also use Traefik since we want to host multiple websites on our home server, just like you would on any server.</p>\n<p>The main question is where to run Traefik:</p>\n<ol>\n<li>On the VPS</li>\n<li>On the home server</li>\n</ol>\n<p>I highly prefer option 2 because, that way, the entire configuration is stored on our home server. The home server is almost tunnel-agnostic, and you can reuse it on any tunneled or non-tunneled server. Otherwise, we would need to maintain state between the VPS and the home server, debug both together, etc.</p>\n<p>Another point is that, with option 2, we avoid the gap of unencrypted traffic on the VPS between Traefik (TLS) and Rathole (Noise Protocol). You can read more about the comparison of these two options in this article: <a href=\"https://blog.mni.li/posts/caddy-rathole-zero-knowledge/\">https://blog.mni.li/posts/caddy-rathole-zero-knowledge/</a>.</p>\n<p>The downside is that Rathole will exclusively occupy ports <code>80</code> and <code>443</code> on the VPS, preventing any other process from using them. We won’t be able to run other web servers on that VPS, so it’s best to use a small one dedicated to this purpose.</p>\n<p>Unless we use a load balancer <a href=\"/blog/2025-05-29-traefik-load-balancer\">Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers</a>.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={RatholeTraefikArchitectureImage} alt=“Rathole Traefik architecture diagram” /></p>\n<h2 id=\"rathole-server\">Rathole server</h2>\n<p>We will run the Rathole server inside a Docker container on our VPS. Rathole uses the same binary for both the server and client, you just pass the right option (<code>--server</code> or <code>--client</code>) and the <code>.toml</code> configuration file.</p>\n<p>Here is the Rathole server configuration <a href=\"https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/rathole.server.toml\">rathole.server.toml</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"toml\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:2333\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">type = </span><span style=\"color:#9ECBFF\">\"noise\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">noise</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_private_key = </span><span style=\"color:#9ECBFF\">\"private_key\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">traefik-http</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:5080\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">server</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">traefik-https</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">bind_addr = </span><span style=\"color:#9ECBFF\">\"0.0.0.0:5443\"</span></span></code></pre>\n<p>Let’s explain it: we choose port <code>2333</code> for the control channel and bind it to all interfaces inside the Docker container with the <code>0.0.0.0</code> IP. We choose the <code>noise</code> encryption protocol and specify a private key. The public key will be used on the Rathole client. The public and private key pair is generated with:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> run</span><span style=\"color:#79B8FF\"> -it</span><span style=\"color:#79B8FF\"> --rm</span><span style=\"color:#9ECBFF\"> rapiz1/rathole</span><span style=\"color:#79B8FF\"> --genkey</span></span></code></pre>\n<p>Then we define two tunnels: one for HTTP and another for HTTPS. For the HTTP tunnel, we define the name <code>server.services.traefik-http</code>, set the value for <code>token</code>, and choose port <code>5080</code>, and again we bind it to all container interfaces with <code>0.0.0.0</code>. Similarly, for HTTPS, we set the name to <code>server.services.traefik-https</code>, provide a <code>token</code> value, and choose port <code>5443</code>.</p>\n<p>Every tunnel has to have a unique name, token value, and port. With that fulfilled, a single Rathole server instance can have as many Rathole clients as needed, which is pretty convenient. For example, besides the existing home server on ports <code>5080</code> and <code>5443</code>, we can expose another one using ports <code>5081</code> and <code>5444</code>.</p>\n<p>Token is just a random base64 string, we generate it by running this:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">openssl</span><span style=\"color:#9ECBFF\"> rand</span><span style=\"color:#79B8FF\"> -base64</span><span style=\"color:#79B8FF\"> 32</span></span></code></pre>\n<p>After configuration file we define a Rathole server container with <a href=\"https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/docker-compose.yml\">docker-compose.yml</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  rathole</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rapiz1/rathole:v0.5.0</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">--server /config/rathole.server.toml</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # host:container</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">2333:2333</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">80:5080</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">443:5443</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./rathole.server.toml:/config/rathole.server.toml:ro</span></span></code></pre>\n<p>In the command, we set the <code>--server</code> option, pass the <code>.toml</code> configuration file, and mount it as a read-only bind-mount volume.</p>\n<p>The important part is the port mappings. Here, you can see that the Rathole server container will occupy ports <code>2333</code>, <code>80</code>, and <code>443</code> exclusively on the host VPS. This practically means we won’t be able to run any other web servers on ports <code>80</code> and <code>443</code>. We will also need to open ports <code>80</code>, <code>443</code>, and <code>2333</code> in the VPS firewall. You don’t need to open ports <code>5080</code> and <code>5443</code>, those are used only by Rathole internally.</p>\n<h2 id=\"rathole-client-and-connecting-with-traefik\">Rathole client and connecting with Traefik</h2>\n<p>We run the Rathole client and Traefik inside Docker containers on the home server. Configuring the Rathole client and connecting it to Traefik is a bit more complex and tricky.</p>\n<p>Here is the Rathole client configuration <a href=\"https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/rathole.client.toml.example\">core/rathole.client.toml.example</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"toml\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">remote_addr = </span><span style=\"color:#9ECBFF\">\"123.123.123.123:2333\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">type = </span><span style=\"color:#9ECBFF\">\"noise\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">transport</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">noise</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">remote_public_key = </span><span style=\"color:#9ECBFF\">\"public_key\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># this is the important part</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># Rathole knows traffic comes from 5080 and 5443, control channel told him</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># DON'T do ANY mapping in docker-compose.yml</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># just pass traffic from Rathole on ports which Traefik expects (80 and 443)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">traefik-http</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_addr = </span><span style=\"color:#9ECBFF\">\"traefik:80\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">[</span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">services</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">traefik-https</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">token = </span><span style=\"color:#9ECBFF\">\"secret_token_1\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">local_addr = </span><span style=\"color:#9ECBFF\">\"traefik:443\"</span></span></code></pre>\n<p>Let’s go through it. First, we define the VPS server IP <code>remote_addr</code>, the control channel port <code>2333</code>, set the <code>noise</code> encryption protocol, and this time specify a public key <code>remote_public_key</code>.</p>\n<p>Now comes the important and tricky part: defining tunnels and services. We repeat the service name and token that we used in the Rathole server config.</p>\n<p><strong>And now the most important part:</strong> <code>local_addr</code>, for this we target the Traefik hostname - service name from <code>core/docker-compose.local.yml</code> and the Traefik listening ports <code>80</code> and <code>443</code>. That’s it. It might look simple and obvious, this is the correct setup. I must emphasize: don’t fall into temptation of setting any additional port mappings in <code>core/docker-compose.local.yml</code>, functionality will break, all should be done in <code>core/rathole.client.toml</code>.</p>\n<p>Another note: You might wonder why ports <code>5080</code> and <code>5443</code> aren’t repeated anywhere in the client config <code>core/rathole.client.toml</code>. The answer is “no need for it”, we already specified port <code>2333</code> for the control channel, which will communicate all additional required information between the Rathole server and client.</p>\n<p>Now that we have configured the Rathole client, we need to define Rathole client and Traefik containers.</p>\n<p>Here is the Rathole client container and the important part of the Traefik container <a href=\"https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/docker-compose.local.yml\">core/docker-compose.local.yml</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  rathole</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # 1. default official x86 image</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rapiz1/rathole:v0.5.0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # 2. custom built ARM image (for Raspberry pi)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # image: nemanjamitic/my-rathole-arm64:v1.0</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # 3. build for arm - AVOID, use prebuilt ARM image above</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # build: https://github.com/rapiz1/rathole.git#main</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # platform: linux/arm64</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    command</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">--client /config/rathole.client.toml</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./rathole.client.toml:/config/rathole.client.toml:ro</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  traefik</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'traefik:v2.9.8'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">traefik</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # for this to work both services must be defined in the same docker-compose.yml file</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    depends_on</span><span style=\"color:#E1E4E8\">: </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">rathole</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # other config...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # leave this commented out, just for explanation</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Rathole will pass Traffic through proxy network directly on 80 and 443</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # defined in rathole.client.toml</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ports:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    #   - '80:80'</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    #   - '443:443'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # other config...</span></span></code></pre>\n<p>Let’s start with the Rathole service. Similarly to the server command, we run the Rathole binary, this time in client mode with <code>--client</code> and we pass the client config file <code>/config/rathole.client.toml</code> which we also bind mount as volume. An important part is that we set both the Rathole and Traefik containers on the same <strong>external</strong> network <code>proxy</code> so they can communicate with each other and with the host.</p>\n<p>Additional notes about the Rathole image:</p>\n<ul>\n<li>Always make sure to use the same Rathole image version for both the server and client for compatibility.</li>\n<li><code>x86</code> - By default, Rathole provides only the <code>x86</code> image. If your home server uses that architecture, you are good to go.</li>\n<li><code>ARM</code> - If you have an ARM home server (e.g., Raspberry Pi), you will have to build the image yourself or use a prebuilt, unofficial one. <strong>Avoid</strong> building images on low-power ARM single-board computers, as it will take a long time and require a lot of RAM and CPU power. Instead, either pre-build one yourself and push it to Docker Hub, or you can reuse my <code>nemanjamitic/my-rathole-arm64:v1.0</code> image (which uses Rathole <code>v0.5.0</code>).</li>\n</ul>\n<p>Now, the Traefik container. It must be on the same <code>proxy</code> external network as Rathole. Another important part: It must <strong>wait</strong> for the Rathole container to boot up <code>depends_on: rathole</code>, because the traffic will come from the Rathole tunnel. <strong>Do not</strong> expose ports <code>80</code> and <code>443</code>, Rathole has already bound those Traefik container ports, as we defined in the Rathole client config <code>core/rathole.client.toml</code>.</p>\n<p>The rest of the Traefik container definition is left out here because it’s the usual configuration, unrelated to the Rathole tunnel. Below is a quick reminder about the general Traefik configuration.</p>\n<p><strong>Traefik reminder</strong></p>\n<ol>\n<li>Provide the <code>.env</code> file with variables needed for Traefik:</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">cp</span><span style=\"color:#9ECBFF\"> .env.example</span><span style=\"color:#9ECBFF\"> .env</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#E1E4E8\">SITE_HOSTNAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">homeserver.my-domain.com</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># important: must put value in quotes \"...\" and escape $ with \\$</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">TRAEFIK_AUTH</span><span style=\"color:#F97583\">=</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># will receive expiration notifications</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">TRAEFIK_LETSENCRYPT_EMAIL</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">myname@example.com</span></span></code></pre>\n<ol start=\"2\">\n<li>On your home server host OS you must create an external Docker network:</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> network</span><span style=\"color:#9ECBFF\"> create</span><span style=\"color:#9ECBFF\"> proxy</span></span></code></pre>\n<ol start=\"3\">\n<li>Create <code>acme.json</code> file with permission <code>600</code>:</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">touch</span><span style=\"color:#9ECBFF\"> ~/homelab/traefik-proxy/core/traefik-data/acme.json</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> chmod</span><span style=\"color:#79B8FF\"> 600</span><span style=\"color:#9ECBFF\"> ~/homelab/traefik-proxy/core/traefik-data/acme.json</span></span></code></pre>\n<ol start=\"4\">\n<li>Always start with the staging Acme server for testing and swap to production once satisfied:</li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#6A737D\"># core/traefik-data/traefik.yml</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">certificatesResolvers</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  letsencrypt</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    acme</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # always start with staging certificate</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">      caServer</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">\"https://acme-staging-v02.api.letsencrypt.org/directory\"</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # caServer: 'https://acme-v02.api.letsencrypt.org/directory'</span></span></code></pre>\n<ol start=\"5\">\n<li>To clear the temporary staging certificates, clear the contents of <code>acme.json</code></li>\n</ol>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">truncate</span><span style=\"color:#79B8FF\"> -s</span><span style=\"color:#79B8FF\"> 0</span><span style=\"color:#9ECBFF\"> acme.json</span></span></code></pre>\n<p>That’s it. Once done, we can run Rathole client and Traefik containers on our home server with:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">docker</span><span style=\"color:#9ECBFF\"> compose</span><span style=\"color:#79B8FF\"> -f</span><span style=\"color:#9ECBFF\"> docker-compose.local.yml</span><span style=\"color:#9ECBFF\"> up</span><span style=\"color:#79B8FF\"> -d</span></span></code></pre>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_XL} src={HomeServerContainersImage} alt=“Running containers on the home server” /></p>\n<h2 id=\"exposing-multiple-servers\">Exposing multiple servers</h2>\n<p>Fortunately, Rathole makes it trivial to run multiple tunnels using a single Rathole server. We don’t need to open any additional ports in the firewall or run multiple container instances. What we do need are different tunnel names, token values, and ports. Those must be unique for each tunnel/service. Also, you will need a load balancer to bind ports <code>80</code> and <code>443</code> to more than one destination port, respectively.</p>\n<p>I wrote a detailed tutorial on how to expose multiple home servers using a single Rathole server. You can read it here: <a href=\"/blog/2025-05-29-traefik-load-balancer\">Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers</a>.</p>\n<h2 id=\"open-the-firewall-on-the-vps\">Open the firewall on the VPS</h2>\n<p>Like for any webserver, on the VPS you will need to open ports <code>80</code> and <code>443</code> to listen for HTTP/HTTPS traffic. Additionally you will need to open the port <code>2333</code> for the Rathole control channel - tunnel.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_XL} src={FirewallImage} alt=“Opened port for Rathole tunnel in the firewall” /></p>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>Rathole server:</strong> <a href=\"https://github.com/nemanjam/rathole-server\">https://github.com/nemanjam/rathole-server</a></li>\n<li><strong>Rathole client and local Traefik:</strong> <a href=\"https://github.com/nemanjam/traefik-proxy/tree/main/core\">https://github.com/nemanjam/traefik-proxy/tree/main/core</a></li>\n</ul>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>Most consumer-grade internet connections are behind a CGNAT. This setup allows you to bypass CGNAT and host an unlimited number of websites on your home server almost for free. You can use it for web servers in virtual machines, LXC containers, SBC computers, etc. - anywhere you can run Docker.</p>\n<p>It is simple, cheap, and you can set it up in 30 minutes. Like anything, it also has some downsides, one of them is the overhead latency caused by an additional network hop between the VPS and your home network, but it’s a reasonable tradeoff.</p>\n<p>Did you make something similar yourself? Can you see room for improvement? Did you use a different method? You tried to run the code and need help with troubleshooting? Let me know in the comments.</p>\n<img src=\"{OrangePiGifImage.src}\" alt=\"Orange Pi hero image\">\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Rathole repository <a href=\"https://github.com/rapiz1/rathole\">https://github.com/rapiz1/rathole</a></li>\n<li>Local or remote Traefik discussion <a href=\"https://github.com/rapiz1/rathole/issues/169\">https://github.com/rapiz1/rathole/issues/169</a></li>\n<li>Local and remote Traefik comparison, Tailscale benchmarks <a href=\"https://blog.mni.li/posts/caddy-rathole-zero-knowledge/\">https://blog.mni.li/posts/caddy-rathole-zero-knowledge/</a></li>\n<li>Rathole Docker example configuration <a href=\"https://nitinja.in/tech/\">https://nitinja.in/tech/</a></li>\n<li>Rathole <code>.toml</code> environment variables discussion <a href=\"https://github.com/rapiz1/rathole/issues/218\">https://github.com/rapiz1/rathole/issues/218</a></li>\n<li>frp repository <a href=\"https://github.com/fatedier/frp\">https://github.com/fatedier/frp</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-04-29-rathole-traefik-home-server/",
            "title": "Expose home server with Rathole tunnel and Traefik",
            "summary": "Bypass CGNAT permanently and host websites from home.\n",
            "date_modified": "2025-04-29T00:00:00.000Z",
            "date_published": "2025-04-29T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-04-20-ssh-tunnel-docker/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;</p>\n<p>import SshTunnelArchitectureImage from ’../../../../content/post/2025/04-20-ssh-tunnel-docker/_images/ssh-tunnel-architecture-16-9.png’;\nimport FirewallImage from ’../../../../content/post/2025/04-20-ssh-tunnel-docker/_images/firewall.png’;</p>\n<p>import TunnelDemoVideo from ’../../../../content/post/2025/04-20-ssh-tunnel-docker/_images/tunnel-demo.webm’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>Most consumer-grade internet connections are hidden behind CGNAT and are not reachable from the internet. This is done to save IP addresses, as IPv4 has a limited range. If you have a static public IPv4 or any IPv6 address, you won’t need the setup from this tutorial.</p>\n<p>There are already services like <a href=\"https://github.com/localtunnel/localtunnel\">localtunnel</a> or <a href=\"https://github.com/ngrok\">ngrok</a> for this purpose, but when you actually start using them, you will often find out that they have limitations on their free plans. So, we will configure our own custom setup once and have it always available for convenient and practical usage which will save a lot of time and nerves in the long run.</p>\n<h2 id=\"why-is-this-useful\">Why is this useful</h2>\n<p>This is useful whenever you need to share your local project with others or provide a publicly accessible URL for your service so that external systems can reach it. This is often the case if you work remotely.</p>\n<p>Yes, you can use test deployments, but having a tunnel setup configured and being able to run it with a single terminal command saves a lot of time and energy.</p>\n<p>Possible use cases:</p>\n<ul>\n<li>Sharing work in progress with clients or teammates</li>\n<li>Remote debugging or pair programming</li>\n<li>Demos for presentations or team meetings</li>\n<li>Testing the frontend on different devices (mobile, different resolution, OS, browser)</li>\n<li>Testing webhooks from external services (Stripe, GitHub, Oauth, Slack, Contentful, Twilio, etc.)</li>\n</ul>\n<h2 id=\"prerequisites\">Prerequisites</h2>\n<ul>\n<li>A VPS server with a public IP and Docker</li>\n<li>A local machine with a working SSH and dev server that you want to expose</li>\n<li>A domain name (optional)</li>\n</ul>\n<h2 id=\"demo-video\">Demo video</h2>\n<p>&#x3C;video {…IMAGE_SIZES.FIXED.MDX_LG} controls>\n<source src=\"{TunnelDemoVideo}\" type=\"video/webm\">\n</p>\n<h2 id=\"architecture-overview\">Architecture overview</h2>\n<p>Without going too deep into computer networking theory, let’s explain port forwarding in simplified terms. Port forwarding is a mapping (binding) between two points (services) on a private network (or even on the same machine) that would otherwise be unreachable. You can think of it as a VPN for a single service (port).</p>\n<p>So it’s exactly what we need: we want to bind (redirect traffic from) a public port (1081 in our case) on the VPS, which acts as a gateway, to port 3000 on our local dev server that is not directly reachable from the internet. That’s it for the tunneling part, this setup is sufficient for serving HTTP traffic.</p>\n<p>Additionally, to support HTTPS and provide a user-friendly URL, we will add Traefik, which will handle HTTPS certificates and route traffic from port 443 to port 1081 of the tunnel.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={SshTunnelArchitectureImage} alt=“SSH tunnel architecture diagram” /></p>\n<h2 id=\"running-the-ssh-server-in-docker\">Running the SSH server in Docker</h2>\n<p>We already use SSH to access our VPS, but we prefer to keep that configuration untouched. So, we will run a separate SSH server inside a Docker container specifically for tunneling.</p>\n<p>For this, we will use <a href=\"https://github.com/linuxserver/docker-openssh-server\">linuxserver/openssh-server</a> image. <a href=\"https://github.com/linuxserver\">Linuxserver</a> is an organization that maintains very stable Dokcer images for all kinds of purposes.</p>\n<p>By default, the SSH server doesn’t allow tunneling, so we need to modify the config in <code>/etc/ssh/sshd_config</code> and enable it.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">AllowTcpForwarding</span><span style=\"color:#9ECBFF\"> yes</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">GatewayPorts</span><span style=\"color:#9ECBFF\"> yes</span></span></code></pre>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> nano</span><span style=\"color:#9ECBFF\"> /etc/ssh/sshd_config</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># edit config...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">sudo</span><span style=\"color:#9ECBFF\"> systemctl</span><span style=\"color:#9ECBFF\"> restart</span><span style=\"color:#9ECBFF\"> sshd</span></span></code></pre>\n<p>But since we are using a Docker container, we will do it differently.</p>\n<p>We will use <a href=\"https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel\">openssh-server-ssh-tunnel</a> mod, which enables tunnelling in the <code>linuxserver/openssh-server</code> image. You can think of mods as presets (additional layers and configurations) for these images.</p>\n<p>Here is <code>docker-compose.yml</code> for the SSH tunnel container:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'3.8'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    hostname</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">openssh-server</span><span style=\"color:#6A737D\"> #optional</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    expose</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1081</span><span style=\"color:#6A737D\"> # tunneled service port, for Traefik</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">1080:2222</span><span style=\"color:#6A737D\"> # 1080 is the main SSH connection port</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">SHELL_NOLOGIN=false</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # set correct for current host user</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PUID=1001</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PGID=1001</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">TZ=Etc/UTC</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # important</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PUBLIC_KEY</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # optional env vars bellow</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">SUDO_ACCESS=true</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">USER_NAME=username</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PASSWORD_ACCESS=false</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./config:/config</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Traefik configuration bellow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.enable=true'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.docker.network=proxy'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.entrypoints=websecure'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.service=ssh-tunnel'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081'</span><span style=\"color:#6A737D\"> # matches exposed port</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  proxy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    external</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<p>Lets explain the code above:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">1080:2222</span><span style=\"color:#6A737D\"> # 1080 is the main SSH connection port</span></span></code></pre>\n<p>By default, <code>linuxserver/docker-openssh-server</code> runs the SSH service on port <code>2222</code>, to avoid conflicting with the usual port <code>22</code> that is used for host’s SSH service and it’s hardcoded in the <a href=\"https://github.com/linuxserver/docker-openssh-server/blob/76dd1c4a0101a694ec848c1e975c9e33a7945d0a/Dockerfile#L39\">Dockerfile</a>. We will choose <strong>port <code>1080</code> for the main SSH connection</strong>, so we need to map it to port <code>2222</code> with SSH in the container. Port <code>1080</code> is used for the actual connection over the internet, and <strong>it is required to allow that port in VPS firewall.</strong></p>\n<p>So, let’s establish clear and precise naming from the beginning:</p>\n<ul>\n<li>Port <code>1080</code> - the main SSH connection port</li>\n<li>Ports <code>1081, 1082, 1083, ...</code> - tunneled services remote ports</li>\n</ul>\n<p>Additionally, you need to configure the SSH client on your dev machine to use port <code>1080</code> for SSH when connecting to this container. In this example I have named VPS host <code>amd1</code> and SSH container host <code>amd1c</code>, you can use your own naming logic.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># ssh amd1 ssh container</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">Host</span><span style=\"color:#9ECBFF\"> amd1c</span><span style=\"color:#79B8FF\"> 123.123.123.123</span><span style=\"color:#6A737D\"> # VPS IP</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    HostName</span><span style=\"color:#79B8FF\"> 123.123.123.123</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    IdentityFile</span><span style=\"color:#9ECBFF\"> ~/.ssh/my-keys/amd1_ssh_container__id_ed25519</span><span style=\"color:#6A737D\"> # private key file name</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    User</span><span style=\"color:#9ECBFF\"> username</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    Port</span><span style=\"color:#79B8FF\"> 1080</span></span></code></pre>\n<p>In the client SSH config above, you will notice the private key file <code>amd1_ssh_container__id_ed25519</code>. The public key is passed to the SSH container as an environment variable:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PUBLIC_KEY</span><span style=\"color:#6A737D\"> # important</span></span></code></pre>\n<p>You generate SSH key pairs as usual, e.g.:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#B392F0\">ssh-keygen</span><span style=\"color:#79B8FF\"> -t</span><span style=\"color:#9ECBFF\"> ed25519</span><span style=\"color:#79B8FF\"> -C</span><span style=\"color:#9ECBFF\"> \"myemail@gmail.com\"</span><span style=\"color:#79B8FF\"> -f</span><span style=\"color:#9ECBFF\"> ~/.ssh/my-keys/amd1_ssh_container__id_ed25519</span></span></code></pre>\n<p>Now, we choose which remote port we will use to expose our local dev server. If you’re using Traefik and don’t access this port directly via the browser, you don’t need to allow it in the VPS’s firewall.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    expose</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1081</span><span style=\"color:#6A737D\"> # tunneled service remote port</span></span></code></pre>\n<p>The other environment variables worth mentioning are the following:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PUID=1001</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PGID=1001</span></span></code></pre>\n<p>We use <code>DOCKER_MODS</code> variable to specify <code>openssh-server-ssh-tunnel</code> mod. <code>PUID</code> and <code>PGID</code> are user and group IDs used to handle permissions between the host and the container. You get their values by running <code>id -u &#x26;&#x26; id -g</code> on the VPS host. It is also a good idea to export them as global environment variables in <code>~/.bashrc</code> file to make them available for all containers:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#E1E4E8\"> MY_UID</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">id</span><span style=\"color:#79B8FF\"> -u</span><span style=\"color:#E1E4E8\">)</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#E1E4E8\"> MY_GID</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">$(</span><span style=\"color:#B392F0\">id</span><span style=\"color:#79B8FF\"> -g</span><span style=\"color:#E1E4E8\">)</span></span></code></pre>\n<p>Then you can pass them like this:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#6A737D\">  # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    - </span><span style=\"color:#9ECBFF\">PUID=$MY_UID</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    - </span><span style=\"color:#9ECBFF\">PGID=$MY_GID</span></span></code></pre>\n<p>The SSH tunnel is now configured. Now you can access your local dev server by HTTP via the your VPS IP e.g. <code>http://123.123.123.123:1081</code> or domain <code>http://my-domain.com:1081</code>.</p>\n<h2 id=\"configuring-https-with-traefik\">Configuring HTTPS with Traefik</h2>\n<p>Some browsers disallow insecure HTTP traffic by default, and you need to tweak the browser settings to allow it explicitly. This can be inconvenient when sending a demo link to a non-technical person. Additionally, some OAuth providers require HTTPS even for testing (e.g. Facebook). So let’s make an extra effort to do things properly and configure a HTTPS with a subdomain using Traefik.</p>\n<p>If you are running a VPS, chances are you already use a reverse proxy for handling certificates and subdomain routing. This example shows how to do it with Traefik.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    expose</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1081</span><span style=\"color:#6A737D\"> # tunneled service remote port</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">1080:2222</span><span style=\"color:#6A737D\"> # 1080 is the main SSH connection port</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Traefik configuration bellow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.enable=true'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.docker.network=proxy'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.entrypoints=websecure'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.service=ssh-tunnel'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081'</span><span style=\"color:#6A737D\"> # matches exposed port</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  proxy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    external</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<p>The truth is, there is not much work to do here. All you need to do is to map the remote port of the tunnel <code>1081</code> to Traefik and define the URL on which you want to expose your local dev server via the environment variable e.g. <code>SITE_HOSTNAME=preview.my-domain.com</code>.</p>\n<p>Everything else is just generic Traefik configuration. Also, don’t forget to add the wildcard A record for your subdomains (e.g., you might add a <code>*.tunnels</code> “namespace”) in your DNS provider’s dashboard and point it to your VPS IP. Additionally, create an external Docker network, e.g. named <code>proxy</code> as shown in the example above.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    expose</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1081</span><span style=\"color:#6A737D\"> # tunneled service remote port, passed to Traefik</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # ...</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Traefik configuration bellow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'</span><span style=\"color:#6A737D\"> # in .env file: SITE_HOSTNAME=my-domain.com</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081'</span><span style=\"color:#6A737D\"> # matches the exposed port</span></span></code></pre>\n<p>In the end, you just need to define 2 environment variables for your <code>docker-compose.yml</code> inside the <code>.env</code> file:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># full with subdomain, without 'https://'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">SITE_HOSTNAME</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">my-domain.com</span><span style=\"color:#6A737D\"> # or e.g. preview.my-domain.com</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># public ssh key</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">PUBLIC_KEY</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">my-public-ssh-key</span></span></code></pre>\n<p>Above is shown only the relevant Traefik configuration for the SSH tunnel container. A complete Traefik reverse proxy configuration requires additional static and dynamic configurations for the Traefik container, but that is outside the scope of this tutorial. You can search for examples of Traefik configurations or reuse mine, which is available in this repository: <a href=\"https://github.com/nemanjam/traefik-proxy\">nemanjam/traefik-proxy</a>.</p>\n<h2 id=\"tunneling-multiple-services\">Tunneling multiple services</h2>\n<p>Sometimes your app runs more than a single service, e.g. frontend and backend. If you expose just the frontend from port 3000, note that <code>localhost</code> from, e.g. <code>localhost:5000</code> won’t be resolved. Therefore, you need to tunnel all services and set the tunneled URLs in your <code>.env</code> files.</p>\n<p>How to have more than one tunnel? Your first thought might be to run multiple SSH server containers, but fortunately, that is not necessary. You can tunnel as many services as you want through a single SSH connection. You just need to expose multiple ports on the SSH container and map them to multiple Traefik hosts with labels, as shown below:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"yml\"><code><span class=\"line\"><span style=\"color:#85E89D\">version</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'3.8'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">services</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  openssh-server</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    image</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">linuxserver/openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    container_name</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">openssh-server</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    restart</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">unless-stopped</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    hostname</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">openssh-server</span><span style=\"color:#6A737D\"> #optional</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # tunneled services, remote ports</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    expose</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1081</span><span style=\"color:#6A737D\"> # tunnel1</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1082</span><span style=\"color:#6A737D\"> # tunnel2</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#79B8FF\">1083</span><span style=\"color:#6A737D\"> # tunnel3</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    ports</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">1080:2222</span><span style=\"color:#6A737D\"> # 1080 is the main SSH connection port</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    environment</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">SHELL_NOLOGIN=false</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # set correct for current host user</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PUID=1001</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PGID=1001</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">TZ=Etc/UTC</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # important</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PUBLIC_KEY</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # optional env vars bellow</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">SUDO_ACCESS=true</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">USER_NAME=username</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">PASSWORD_ACCESS=false</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    volumes</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">./config:/config</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    # Traefik configuration bellow</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    labels</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # common config</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.enable=true'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.docker.network=proxy'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # tunnel1 (port 3000 -> 1081)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel1.rule=Host(`preview1.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel1.entrypoints=websecure'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel1.service=ssh-tunnel1'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.services.ssh-tunnel1.loadbalancer.server.port=1081'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # tunnel2 (port 5000 -> 1082)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel2.rule=Host(`preview2.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel2.entrypoints=websecure'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel2.service=ssh-tunnel2'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.services.ssh-tunnel2.loadbalancer.server.port=1082'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">      # tunnel3 (port 5001 -> 1083)</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel3.rule=Host(`preview3.${SITE_HOSTNAME}`)'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel3.entrypoints=websecure'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.routers.ssh-tunnel3.service=ssh-tunnel3'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">'traefik.http.services.ssh-tunnel3.loadbalancer.server.port=1083'</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      - </span><span style=\"color:#9ECBFF\">proxy</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#85E89D\">networks</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">  proxy</span><span style=\"color:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">    external</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">true</span></span></code></pre>\n<p>If you have a large number of services to tunnel, you might want to use a VPN to access all ports by default, but that’s rarely the case.</p>\n<p>Another point to make is that the SSH tunnel technique is most suitable for temporarily exposing services for demo purposes. For permanent tunnels, you would need to add <code>autossh</code> to keep the connection alive, but there are better tools for permanent tunnels, such as <a href=\"https://github.com/rapiz1/rathole\">rapiz1/rathole</a> or <a href=\"https://github.com/fatedier/frp\">fatedier/frp</a>.</p>\n<h2 id=\"open-the-firewall-on-the-vps\">Open the firewall on the VPS</h2>\n<p>For the main SSH connection, you will need to open a port in your VPS firewall, port <code>1080</code> in this example. Additionally, if you want to access tunnels directly via a port in the browser without Traefik, you will need to open those ports as well. Be mindful not to open too many unnecessary ports, as every newly opened port increases the attack surface.</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={FirewallImage} alt=“Example opened ports in the firewall” /></p>\n<h2 id=\"running-the-tunnel\">Running the tunnel</h2>\n<p>You start the tunnel with a single command like below. The <code>-R</code> option means remote port forwarding, followed by two <code>IP:port</code> pairs. The first pair is remote, and the second is local. At the end, you have the VPS host.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># command format</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#79B8FF\"> -R</span><span style=\"color:#E1E4E8\"> [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># example:</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># amd1c host is defined in ~/.ssh/config</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#79B8FF\"> -R</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\">:1081:localhost:3000</span><span style=\"color:#9ECBFF\"> amd1c</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># access the url, e.g.</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://preview1.my-domain.com</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># terminate tunnel, like any ssh connection</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">exit</span></span></code></pre>\n<p>You can open multiple tunnels with a single command. Just specify the tunnels one after another before the host. Note that you must have these tunnels defined in your <code>docker-compose.yml</code> for the SSH server (exposed ports and Traefik host labels).</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># tunnel frontend at port 3000 and backend at port 5000</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">ssh</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  -R</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\">:1081:localhost:3000</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#79B8FF\">  -R</span><span style=\"color:#79B8FF\"> *</span><span style=\"color:#9ECBFF\">:1082:localhost:5000</span><span style=\"color:#79B8FF\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  amd1c</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># access the urls, e.g.</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://preview1.my-domain.com</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">https://preview2.my-domain.com/api</span></span></code></pre>\n<h2 id=\"completed-code\">Completed code</h2>\n<ul>\n<li><strong>SSH tunnel configuration:</strong> <a href=\"https://github.com/nemanjam/traefik-proxy/tree/a1feed0a4d7dba53f39bb2d5431c0e4d2e170336/apps/ssh-server\">https://github.com/nemanjam/traefik-proxy/tree/main/apps/ssh-server</a></li>\n<li><strong>Traefik configuration:</strong> <a href=\"https://github.com/nemanjam/traefik-proxy/tree/a1feed0a4d7dba53f39bb2d5431c0e4d2e170336/core\">https://github.com/nemanjam/traefik-proxy/tree/main/core</a></li>\n</ul>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>Port forwarding is a basic networking technique that is very familiar to network engineers, but perhaps not often utilized by developers. It can be very useful and practical, especially in a remote work setting. As described in this tutorial, you just need to run a single container, configure the client and firewall, and once you have it set up, it can save you a lot of time and energy in the long run.</p>\n<p>SSH remote port forwarding is just one of the many useful and cool SSH networking tricks. There are many others like dynamic port forwarding, SSH agent forwarding, X11 forwarding, SSH file system, etc. Do you use some of them? Please share in the comments bellow.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Local and remote port forwarding tutorial <a href=\"https://iximiuz.com/en/posts/ssh-tunnels\">https://iximiuz.com/en/posts/ssh-tunnels</a></li>\n<li><code>linuxserver/docker-openssh-server</code> image repository <a href=\"https://github.com/linuxserver/docker-openssh-server\">https://github.com/linuxserver/docker-openssh-server</a></li>\n<li><code>openssh-server-ssh-tunnel</code> mod repository <a href=\"https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel\">https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel</a></li>\n<li>Useful discussion that suggests to use the existing tunnel mod <a href=\"https://github.com/linuxserver/docker-openssh-server/issues/22\">https://github.com/linuxserver/docker-openssh-server/issues/22</a></li>\n<li>The list of all available Linuxserver mods <a href=\"https://github.com/linuxserver/docker-mods\">https://github.com/linuxserver/docker-mods</a>, <a href=\"https://mods.linuxserver.io\">https://mods.linuxserver.io</a></li>\n<li>The list of all available Linuxserver images <a href=\"https://www.linuxserver.io/our-images\">https://www.linuxserver.io/our-images</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-04-20-ssh-tunnel-docker/",
            "title": "Expose local dev server with SSH tunnel and Docker",
            "summary": "A practical example how to temporarily expose your local service to the internet.\n",
            "date_modified": "2025-04-20T00:00:00.000Z",
            "date_published": "2025-04-20T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-04-06-random-image-component/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;</p>\n<p>import LayoutShiftImage from ’../../../../content/post/2025/04-06-random-image-component/_images/layout-shift.png’;</p>\n<p>import OverviewVideo from ’../../../../content/post/2025/04-06-random-image-component/_images/overview.webm’;\nimport ResponsiveImagesVideo from ’../../../../content/post/2025/04-06-random-image-component/_images/responsive-images.webm’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>For the sake of practice and fun let’s build a component that displays a random image on mouse click. It looks more fun and interactive than a static hero image. You can see it in action on the Home page of the my website.</p>\n<p>This functionality shares some common parts with the image gallery described in the previous article, such as the component hierarchy and including urls in the client. However, it also introduces some new elements, like a proper blur preloader.</p>\n<h2 id=\"what-we-will-be-building\">What we will be building</h2>\n<ul>\n<li><strong>Demo:</strong> <a href=\"https://nemanjamitic.com/\">https://nemanjamitic.com/</a></li>\n<li><strong>Github repository:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io\">https://github.com/nemanjam/nemanjam.github.io</a></li>\n</ul>\n<p>&#x3C;video {…IMAGE_SIZES.FIXED.MDX_LG} controls>\n<source src=\"{OverviewVideo}\" type=\"video/webm\">\n</p>\n<h2 id=\"component-hierarchy\">Component hierarchy</h2>\n<p>Again, we will use the similar structure <code>MDX (index.mdx) -> Astro component (ImageRandom.astro) -> React components (ImageRandomReact.jsx and ImageBlurPreloader.jsx)</code>, and again, the client React components contain the most complexity.</p>\n<p>Code (paraphrased):</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/pages/index.mdx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">ImageRandom</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/ImageRandom.astro</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">ImageRandomReact</span><span style=\"color:#E1E4E8\"> {galleryImages} </span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">:</span><span style=\"color:#B392F0\">load</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/react/ImageRandomReact.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">ImageBlurPreloader</span><span style=\"color:#E1E4E8\"> {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">props} /></span></span></code></pre>\n<h2 id=\"responsive-image\">Responsive image</h2>\n<p>This is the functionality shared with the image gallery. This time, we’ll use a fixed low-resolution image for the blur effect and a responsive, high-resolution hero image as the main one. The blur and main images will have different resolutions but share the same <code>16:9</code> aspect ratio.</p>\n<p>The code is as follows:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/constants/image.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> IMAGE_SIZES</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  FIXED: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // blur image</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    BLUR_16_9: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      width: </span><span style=\"color:#79B8FF\">64</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      height: </span><span style=\"color:#79B8FF\">36</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    },</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...  </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  RESPONSIVE: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // main image</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    POST_HERO: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      widths: [</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">XS</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">SM</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">MD</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">LG</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      sizes: </span><span style=\"color:#9ECBFF\">`(max-width: ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">XS</span><span style=\"color:#9ECBFF\">}px) ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">XS</span><span style=\"color:#9ECBFF\">}px, (max-width: ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">SM</span><span style=\"color:#9ECBFF\">}px) ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">SM</span><span style=\"color:#9ECBFF\">}px, (max-width: ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">MD</span><span style=\"color:#9ECBFF\">}px) ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">MD</span><span style=\"color:#9ECBFF\">}px, ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">LG</span><span style=\"color:#9ECBFF\">}px`</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    },</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...  </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// actual &#x3C;img /> tag attributes that are generated with the POST_HERO</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">&#x3C;</span><span style=\"color:#E1E4E8\">img</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  sizes</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"(max-width: 475px) 475px, (max-width: 640px) 640px, (max-width: 768px) 768px, 1024px\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  width</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"3264\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  height</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"1836\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  srcset</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    /_astro/amfi1.Cv2xkJ5B_1Lofkq.webp 475w</span><span style=\"color:#FDAEB7;font-style:italic\">,</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    /</span><span style=\"color:#E1E4E8\">_astro</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">amfi1.Cv2xkJ5B_Oxmi8.webp 640w,</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    /</span><span style=\"color:#E1E4E8\">_astro</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">amfi1.Cv2xkJ5B_X0wXS.webp 768w,</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    /</span><span style=\"color:#E1E4E8\">_astro</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">amfi1.Cv2xkJ5B_Z1u01H4.webp 1024w</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  src=\"</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">_astro</span><span style=\"color:#F97583\">/</span><span style=\"color:#E1E4E8\">amfi1.Cv2xkJ5B_26HGs8.webp</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">/</span><span style=\"color:#FDAEB7;font-style:italic\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/libs/gallery/transform.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> heroImageOptions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  ...</span><span style=\"color:#79B8FF\">IMAGE_SIZES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">RESPONSIVE</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">POST_HERO</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/libs/gallery/images.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getHeroImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> ()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">HeroImage</span><span style=\"color:#E1E4E8\">[]> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> blur</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> getCustomImages</span><span style=\"color:#E1E4E8\">(blurImageOptions);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> hero</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> getCustomImages</span><span style=\"color:#E1E4E8\">(heroImageOptions);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> heroImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> mergeArrays</span><span style=\"color:#E1E4E8\">(blur, hero).</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">(([</span><span style=\"color:#FFAB70\">blur</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">hero</span><span style=\"color:#E1E4E8\">]) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    blur: </span><span style=\"color:#B392F0\">imageResultToImageAttributes</span><span style=\"color:#E1E4E8\">(blur),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    hero: </span><span style=\"color:#B392F0\">imageResultToImageAttributes</span><span style=\"color:#E1E4E8\">(hero),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }));</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> heroImages;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/ImageRandom.astro</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> galleryImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> getHeroImages</span><span style=\"color:#E1E4E8\">();</span></span></code></pre>\n<p><strong>Responsive main image in action:</strong></p>\n<p>&#x3C;video {…IMAGE_SIZES.FIXED.MDX_LG} controls>\n<source src=\"{ResponsiveImagesVideo}\" type=\"video/webm\">\n</p>\n<h2 id=\"random-image-in-a-static-website\">Random image in a static website</h2>\n<p>Again, we have the same situation as in the image gallery. The key point is to include all image urls in the client and execute <code>getRandomElementFromArray()</code> in the client React component to display a random image at runtime. If we called the random function on the server, in the Astro component, we would end up with a single image that was randomly picked at build time - which is not what we want.</p>\n<p>This is the code:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#F97583\">---</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/ImageRandom.astro</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> galleryImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> getHeroImages</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">{</span><span style=\"color:#6A737D\">/* include all the images in the client and let the client pick the random image */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">  {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">props}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#79B8FF\">ImageRandomReact</span><span style=\"color:#E1E4E8\"> {galleryImages} </span><span style=\"color:#B392F0\">client</span><span style=\"color:#E1E4E8\">:</span><span style=\"color:#B392F0\">load</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/react/ImageRandom.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> ImageRandomReact</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#FFAB70\">galleryImages</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">className</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">divClassName</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#F97583\">...</span><span style=\"color:#FFAB70\">props</span><span style=\"color:#E1E4E8\"> }) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // cache randomized images</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> randomImage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useMemo</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#B392F0\"> getRandomElementFromArray</span><span style=\"color:#E1E4E8\">(galleryImages), [galleryImages]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">image</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setImage</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">(initialImage);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // pick initial random image on mount</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    setImage</span><span style=\"color:#E1E4E8\">(randomImage);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }, [setImage, randomImage]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // pick random image onClick</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#B392F0\"> handleClick</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    const</span><span style=\"color:#79B8FF\"> randomImage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> getRandomElementFromArray</span><span style=\"color:#E1E4E8\">(galleryImages);</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    setImage</span><span style=\"color:#E1E4E8\">(randomImage);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#79B8FF\">ImageBlurPreloader</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">props}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      blurAttributes</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">image.blur, alt: </span><span style=\"color:#9ECBFF\">'Blur image'</span><span style=\"color:#E1E4E8\"> }}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      mainAttributes</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{{ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">image.hero, onClick: handleClick, alt: </span><span style=\"color:#9ECBFF\">'Hero image'</span><span style=\"color:#E1E4E8\"> }}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'cursor-pointer my-0'</span><span style=\"color:#E1E4E8\">, className)}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      divClassName</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{divClassName}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span></code></pre>\n<h2 id=\"blur-preloader\">Blur preloader</h2>\n<p>This is the most interesting part of the feature. The first instinct when swapping the blur and main images might be to use a ternary operator to mount or unmount the appropriate image. But we actually can’t do that here. Why? Because both images need to remain mounted in the DOM to ensure the <code>onLoad</code> event works correctly for both the blur and main images. So instead of unmounting, we will use absolute positioning to place the main image above the blur image and toggle its opacity to show or hide it.</p>\n<p>But there is more. Note that with the <code>onLoad</code> event, we have three possible values for the image’s src attribute (although the main image actually uses the <code>srcset</code> and <code>sizes</code> attributes). These are:</p>\n<ol>\n<li>An empty string <code>''</code> when both blur and main images are still loading. In this case we will show an empty <code>&#x3C;div /></code> of the same size as the main image.</li>\n<li>The <code>src</code> attribute of the blur image, when the blur image is loaded but the main image is still loading.</li>\n<li>The <code>srcset</code> and <code>sizes</code> attributes of the main image, once the main image has fully loaded.</li>\n</ol>\n<p>This is the code <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/c1e105847d8e7b4ab4aaffad3078726c37f67528/src/components/react/ImageBlurPreloader.tsx\">src/components/react/ImageBlurPreloader.tsx</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> initialAttributes</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ImgTagAttributes</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> { src: </span><span style=\"color:#9ECBFF\">''</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> ImageBlurPreloader</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  blurAttributes </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> initialAttributes,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  mainAttributes </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> initialAttributes,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  onMainLoaded,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  className,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  divClassName,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">isLoadingMain</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setIsLoadingMain</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">isLoadingBlur</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setIsLoadingBlur</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> prevMainAttributes</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> usePrevious</span><span style=\"color:#E1E4E8\">(mainAttributes);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isNewImage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    prevMainAttributes?.src </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> mainAttributes.src </span><span style=\"color:#F97583\">&#x26;&#x26;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    prevMainAttributes.srcSet </span><span style=\"color:#F97583\">===</span><span style=\"color:#E1E4E8\"> mainAttributes.srcSet</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // reset isLoading on main image change</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (isNewImage) {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      setIsLoadingBlur</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      setIsLoadingMain</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }, [isNewImage, setIsLoadingMain, setIsLoadingBlur]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // important: main image must be in DOM for onLoad to work</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // unmount and display: none; will fail</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#B392F0\"> handleLoadMain</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    setIsLoadingMain</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    onMainLoaded</span><span style=\"color:#E1E4E8\">?.();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> commonAttributes</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // blur image must use size from main image</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    width: mainAttributes.width,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    height: mainAttributes.height,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> blurAlt</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">isLoadingBlur </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> blurAttributes.alt </span><span style=\"color:#F97583\">:</span><span style=\"color:#9ECBFF\"> ''</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> mainAlt</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">isLoadingMain </span><span style=\"color:#F97583\">?</span><span style=\"color:#E1E4E8\"> mainAttributes.alt </span><span style=\"color:#F97583\">:</span><span style=\"color:#9ECBFF\"> ''</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> hasImage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> Boolean</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    isLoadingMain</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      ?</span><span style=\"color:#E1E4E8\"> mainAttributes.src </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> mainAttributes.srcSet</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">      :</span><span style=\"color:#E1E4E8\"> blurAttributes.src </span><span style=\"color:#F97583\">||</span><span style=\"color:#E1E4E8\"> blurAttributes.srcSet</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'relative size-full'</span><span style=\"color:#E1E4E8\">, divClassName)}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {hasImage </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          {</span><span style=\"color:#6A737D\">/* blur image */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#85E89D\">img</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">blurAttributes}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">commonAttributes}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            alt</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{blurAlt}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            onLoad</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{() </span><span style=\"color:#F97583\">=></span><span style=\"color:#B392F0\"> setIsLoadingBlur</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">false</span><span style=\"color:#E1E4E8\">)}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'object-cover absolute top-0 left-0 size-full'</span><span style=\"color:#E1E4E8\">, className)}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          {</span><span style=\"color:#6A737D\">/* main image */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#85E89D\">img</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">mainAttributes}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">commonAttributes}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            alt</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{mainAlt}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            onLoad</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{handleLoadMain}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">              'object-cover absolute top-0 left-0 size-full'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">              // important: don't hide main image until next blur image is loaded</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              isLoadingMain </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">isLoadingBlur </span><span style=\"color:#F97583\">?</span><span style=\"color:#9ECBFF\"> 'opacity-0'</span><span style=\"color:#F97583\"> :</span><span style=\"color:#9ECBFF\"> 'opacity-100'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              className</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;/></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<p>That is a lot of code, so let’s break it down. First, note the use of <code>relative</code> and <code>absolute</code> classes to position the images on top of each other.</p>\n<p>We set the initial <code>src</code> to an empty string in the <code>initialAttributes</code> variable. This sets the <code>hasImage</code> flag to <code>true</code>, unmounts the images, and displays an empty <code>&#x3C;div></code> that fills the parent container thanks to the <code>size-full</code> class (which is needed to prevent layout shift).</p>\n<p>Next, note that we track the separate states <code>isLoadingMain</code> and <code>isLoadingBlur</code> for the main and blur images. Both are necessary so we can correctly show/hide the main image by changing its opacity from <code>opacity-0</code> to <code>opacity-100</code>. The general idea is this: “Always keep the blur image below, just show or hide the main image above.”</p>\n<p>Additionally, we track the previous main image, <code>prevMainAttributes</code>, to detect when a new image is selected via the <code>onClick</code> event passed from the parent component.</p>\n<p>Finally, while an image is loading, we set its <code>alt</code> attribute (using the <code>blurAlt</code> and <code>mainAlt</code> variables) to an empty string to avoid rendering text in place of an empty image, as it doesn’t look nice.</p>\n<p><strong>Bonus tip:</strong> You can also experiment with the <code>&#x3C;img style={{imageRendering: 'pixelated'}} /></code> scaling style on the blur image if you find it more aesthetically pleasing.</p>\n<h2 id=\"cumulative-layout-shift\">Cumulative layout shift</h2>\n<p>This is also an interesting part. In general, the server always sends images with their sizes (at least it should), which makes handling layout shifts easier, so we should be able to solve it properly.</p>\n<p>The key point is this: Set the component’s actual size <strong>in the server component</strong> <code>ImageRandom.astro</code> and use <code>w-full h-full</code> (<code>size-full</code>) in the client <code>ImageRandom.tsx</code> React component to stretch it to fill the parent. This way, the size is resolved on the server, and there is no shift when hydrating the client component.</p>\n<p>Lets see it in practice <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/cb36b621ebae583dee693dd6ef6e6ece0028c468/src/components/ImageRandom.astro#L21\">src/components/ImageRandom.astro#L21</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"astro\"><code><span class=\"line\"><span style=\"color:#6A737D\">---</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// add 'px' suffix or styles will fail</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">width</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">height</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> Object.</span><span style=\"color:#B392F0\">fromEntries</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  Object.</span><span style=\"color:#B392F0\">entries</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">IMAGE_SIZES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">FIXED</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">MDX_XL_16_9</span><span style=\"color:#E1E4E8\">).</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">(([</span><span style=\"color:#FFAB70\">key</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">value</span><span style=\"color:#E1E4E8\">]) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> [key, </span><span style=\"color:#9ECBFF\">`${</span><span style=\"color:#E1E4E8\">value</span><span style=\"color:#9ECBFF\">}px`</span><span style=\"color:#E1E4E8\">])</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">{</span><span style=\"color:#6A737D\">/* height and width MUST be defined ON SERVER component to prevent layout shift */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">{</span><span style=\"color:#6A737D\">/* set height and width to image size but set real size with max-height and max-width */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  class</span><span style=\"color:#E1E4E8\">={</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'max-w-full max-h-64 md:max-h-96 my-8'</span><span style=\"color:#E1E4E8\">, className)}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  style</span><span style=\"color:#E1E4E8\">={{ width, height }}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">props}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#79B8FF\">ImageRandomReact</span><span style=\"color:#E1E4E8\"> {galleryImages} </span><span style=\"color:#B392F0\">client:load</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"></span></code></pre>\n<p>We use the <code>max-w-...</code> and <code>max-h-...</code> classes to set the actual (responsive) size for the server component, which the client component will fill.</p>\n<p>The <code>my-8</code> margin is there to override the vertical margin styles for the image component in the markdown (<code>prose</code> class). Remember, we have two actual, absolutely positioned <code>&#x3C;img /></code> tags in the DOM, so <code>prose</code> will add double margins, and we need to correct that.</p>\n<p>Client component <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/c1e105847d8e7b4ab4aaffad3078726c37f67528/src/components/react/ImageBlurPreloader.tsx\">src/components/react/ImageBlurPreloader.tsx</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> ImageBlurPreloader</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> FC</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">Props</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  className,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  divClassName,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'relative size-full'</span><span style=\"color:#E1E4E8\">, divClassName)}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {hasImage </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          {</span><span style=\"color:#6A737D\">/* blur image */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#85E89D\">img</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'object-cover absolute top-0 left-0 size-full'</span><span style=\"color:#E1E4E8\">, className)}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          {</span><span style=\"color:#6A737D\">/* main image */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#85E89D\">img</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">            className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">              'object-cover absolute top-0 left-0 size-full'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">            )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;/></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<p>In the client component, we simply stretch all elements with <code>size-full</code> to fill the parent server component.</p>\n<p>With this in place, we achieve the following score for the cumulative layout shift:</p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={LayoutShiftImage} alt=“Lighthouse score layout shift” /></p>\n<h2 id=\"completed-code-and-demo\">Completed code and demo</h2>\n<ul>\n<li><strong>Demo:</strong> <a href=\"https://nemanjamitic.com/\">https://nemanjamitic.com/</a></li>\n<li><strong>Github repository:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io\">https://github.com/nemanjam/nemanjam.github.io</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># https://github.com/nemanjam/nemanjam.github.io/tree/c1e105847d8e7b4ab4aaffad3078726c37f67528</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> c1e105847d8e7b4ab4aaffad3078726c37f67528</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># random image code</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/pages/index.mdx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/ImageRandom.astro</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/react/ImageRandom.tsx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/react/ImageBlurPreloader.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># common code with gallery</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/libs/gallery/images.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/libs/gallery/transform.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/constants/image.ts</span></span></code></pre>\n<h2 id=\"outro\">Outro</h2>\n<p>Once again, we played around with images, Astro, and React. Have you implemented any similar components yourself, maybe a carousel? What was your approach? Do you have suggestions for improvements or have you spotted anything incorrect? Don’t hesitate to leave a comment below.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>React image preloader tutorial <a href=\"https://benhoneywill.com/progressive-image-loading-with-react-hooks/\">https://benhoneywill.com/progressive-image-loading-with-react-hooks/</a></li>\n<li>Astro documentation, tutorial how to use <code>getImage()</code> function <a href=\"https://docs.astro.build/en/recipes/build-custom-img-component/\">https://docs.astro.build/en/recipes/build-custom-img-component/</a></li>\n<li>“Squared” image scaling algorithm styles <a href=\"https://www.w3schools.com/cssref/css3_pr_image-rendering.php\">https://www.w3schools.com/cssref/css3_pr_image-rendering.php</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-04-06-random-image-component/",
            "title": "Build a random image component with Astro and React",
            "summary": "Build a random image component to make your hero image more interactive and interesting.\n",
            "date_modified": "2025-04-06T00:00:00.000Z",
            "date_published": "2025-04-06T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        },
        {
            "id": "https://docker.nemanjamitic.com/blog/2025-04-02-astro-react-gallery/",
            "content_html": "<p>import { Image } from ‘astro:assets’;</p>\n<p>import { IMAGE_SIZES } from ’../../../../constants/image’;</p>\n<p>import LayoutShiftBeforeImage from ’../../../../content/post/2025/04-02-astro-react-gallery/_images/layout-shift-before.png’;\nimport LayoutShiftAfterImage from ’../../../../content/post/2025/04-02-astro-react-gallery/_images/layout-shift-after.png’;</p>\n<p>import OverviewVideo from ’../../../../content/post/2025/04-02-astro-react-gallery/_images/overview.webm’;\nimport ResponsiveImagesVideo from ’../../../../content/post/2025/04-02-astro-react-gallery/_images/responsive-images-1.5x.webm’;\nimport InfiniteScrollLoaderVideo from ’../../../../content/post/2025/04-02-astro-react-gallery/_images/infinite-scroll-loader.webm’;</p>\n<h2 id=\"introduction\">Introduction</h2>\n<p>I wanted to have a simple, Instagram-like, scroll paginated gallery page on the website where I could share my everyday photos. Initially I implemented it using <a href=\"https://github.com/benhowell/react-grid-gallery\">benhowell/react-grid-gallery</a> package for gallery, and <a href=\"https://github.com/frontend-collective/react-image-lightbox\">frontend-collective/react-image-lightbox</a> for lightbox component. It worked ok, but since those are a bit legacy packages I was unable to upgrade to React 19, it loaded all images at once without scroll pagination and Lighthouse score wasn’t so great.</p>\n<p>You can see that implementation if you navigate back in Git history <a href=\"https://github.com/nemanjam/nemanjam.github.io/tree/e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae\">e0165b</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># in git history navigate back to the old gallery commit</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># preview</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> clean</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> install</span><span style=\"color:#E1E4E8\"> &#x26;&#x26; </span><span style=\"color:#B392F0\">yarn</span><span style=\"color:#9ECBFF\"> dev</span></span></code></pre>\n<p>I decided to reimplement it, did a quick research and decided to make my own gallery component and use <a href=\"https://github.com/dimsemenov/photoswipe\">dimsemenov/photoswipe</a> package for lightbox. And that’s how this article got created, while implementing I took notes about the most important and interesting parts from the process. Look at it as not necessarily the absolute best way to make image gallery with Astro and React but as one of the ways that is proven in practice and works well.</p>\n<h2 id=\"what-we-will-be-building\">What we will be building</h2>\n<ul>\n<li><strong>Demo:</strong> <a href=\"https://nemanjamitic.com/gallery\">https://nemanjamitic.com/gallery</a></li>\n<li><strong>Github repository:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io\">https://github.com/nemanjam/nemanjam.github.io</a></li>\n</ul>\n<p>{/* overview.webm - <a href=\"https://github.com/user-attachments/assets/59744bb9-3c87-4e2c-9b10-1c830f9af554\">https://github.com/user-attachments/assets/59744bb9-3c87-4e2c-9b10-1c830f9af554</a> */}\n&#x3C;video {…IMAGE_SIZES.FIXED.MDX_LG} controls>\n<source src=\"{OverviewVideo}\" type=\"video/webm\">\n</p>\n<h2 id=\"image---server-component-client-component-slot-props\">Image - server component, client component, slot, props</h2>\n<p>This is the first dilemma and initial decision that affects all the future code that we write. Since this is a static website example we are naturally inclined to pre-render everything we can at build time, but can this work for images too?</p>\n<p>Astro provides <code>&#x3C;Image /></code> component and it’s a server component like any other Astro component. It is clear that we will need <code>onLoad</code>, <code>onClick</code> events on a image and events aren’t possible on a server component. Yes, but maybe we can use client component wrapper and pass Astro <code>&#x3C;Image /></code> component as a slot so we can have best from both - Astro component for image optimization and a <code>&#x3C;div /></code> for events, could this work?</p>\n<p>Not really, for any preload effects <code>onLoad</code> event needs to be on the <code>&#x3C;img /></code> tag, but more important is that we can’t pass any client props to the slot <code>&#x3C;Image /></code> component, we can generate only a single instance at build time. For any props values we would need to pregenerate separate image HTML which in this case is highly impractical.</p>\n<p><strong>Conclusion:</strong> We will use a React client component that supports interactivity and Astro <code>getImage()</code> function to optimize the images.</p>\n<h2 id=\"api-route-vs-importmetaglob\">API route vs <code>import.meta.glob()</code></h2>\n<p>We want to stick to a static website, for performance reasons and convenient deployments. What way should we use to pass the image urls to the client? We could make a static API endpoint that serves JSON array. We could even make an parametrized API endpoint that serves optimized images.</p>\n<p>Right away, why having an extra HTTP call for JSON on client when we can pregenerate image urls at build time, it’s not what we want.</p>\n<p>For a static API endpoint, since it’s static we would need to pre-render all params at build time, so we could do <code>http://localhost/api/gallery/xl/image1.webp</code> but not <code>http://localhost/api/gallery/300x200/image1.webp</code> and <code>http://localhost/api/gallery/301x200/image1.webp</code>, for that we would need to enable Astro server side rending and have Node.js runtime in production.</p>\n<p>If we log a <code>src</code> attribute of an imported image in dev and prod mode we will see something like this:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"><span style=\"color:#6A737D\">// in dev</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">http</span><span style=\"color:#E1E4E8\">:</span><span style=\"color:#6A737D\">//localhost:3000/_image?href=/@fs/home/username/Desktop/nemanjam.github.io/src/assets/images/all-images/morning1.jpg?origWidth=4608&#x26;origHeight=2592&#x26;origFormat=jpg&#x26;w=1280&#x26;h=720&#x26;f=webp</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// in prod</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">http</span><span style=\"color:#E1E4E8\">:</span><span style=\"color:#6A737D\">//localhost:3000/_astro/morning1.CEdGhKb3_nVk9T.webp</span></span></code></pre>\n<p>So Astro is already serving images for us, with a dedicated API endpoint we would just accomplish human friendly url rewriting, that could be useful only if some external service fetches those images, which we don’t have here.</p>\n<p><strong>Conclusion:</strong> We will use <code>import.meta.glob('/src/assets/images/all-images/*.jpg')</code> from Vite to import images as modules to obtain images at build time and pass them as props into the Gallery component.</p>\n<p>The code is as follows <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/images.ts#L16\">src/libs/gallery/images.ts#L16</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getGalleryImagesMetadata</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> ()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ImageMetadata</span><span style=\"color:#E1E4E8\">[] </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> imageModules</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> import</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">meta</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">glob</span><span style=\"color:#E1E4E8\">&#x3C;{ </span><span style=\"color:#FFAB70\">default</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ImageMetadata</span><span style=\"color:#E1E4E8\"> }>(</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // can't be a variable</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    '/src/assets/images/all-images/*.jpg'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    { eager: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\"> }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  );</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // convert map to array</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> imagesMetadata</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> Object.</span><span style=\"color:#B392F0\">keys</span><span style=\"color:#E1E4E8\">(imageModules)</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // filter excluded filenames</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">filter</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">path</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> !</span><span style=\"color:#79B8FF\">EXCLUDE_IMAGES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#B392F0\">some</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">excludedFileName</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> path.</span><span style=\"color:#B392F0\">endsWith</span><span style=\"color:#E1E4E8\">(excludedFileName)))</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // return metadata array</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    .</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">path</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> imageModules[path].default);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> imagesMetadata;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h2 id=\"code-structure\">Code structure</h2>\n<p>We will structure code like this: <code>MDX (gallery.mdx) -> Astro component (Gallery.astro) -> React component (Gallery.jsx)</code>. The call stack is top-down, MDX is a declarative presentation layer, Astro component will resolve data - images, React component will handle events and define logic, it’s the most complex layer.</p>\n<p>Code (paraphrased):</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"><span style=\"color:#6A737D\">// src/pages/gallery.mdx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">Gallery</span><span style=\"color:#B392F0\"> class</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"not-prose grow\"</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/Gallery.astro</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">ReactGallery</span><span style=\"color:#B392F0\"> client</span><span style=\"color:#E1E4E8\">:</span><span style=\"color:#B392F0\">only</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"react\"</span><span style=\"color:#B392F0\"> images</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{randomizedGalleryImages} /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/react/Gallery.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3\"</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {loadedImages.</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">image</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">img</span><span style=\"color:#E1E4E8\"> {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">imageProps} /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  )}</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">&#x3C;/</span><span style=\"color:#E1E4E8\">div</span><span style=\"color:#F97583\">></span></span></code></pre>\n<h2 id=\"static-generation-include-image-urls-and-map-on-the-client\">Static generation, include image urls and <code>map()</code> on the client</h2>\n<p>Again, interesting and important point that is easy to forget is that <code>images.map()</code> needs to be in React component in order to have infinite scroll pagination. For that all image urls (and other props) need to be bundled and available on client, that is passed as props from Astro to the React component.</p>\n<p>If we placed <code>images.map()</code> in the Astro component we would we would have a single image list as is without any interactivity (pagination on scroll).</p>\n<p><strong>Reminder:</strong> Static “backend” runs only once - at build time. We have a Node.js runtime only in development, and not in production - in there we have just a webserver static folder for serving assets. Kind of obvious, but it can sometimes be overlooked when we decide whether to put certain code in a server or client component.</p>\n<h2 id=\"responsive-optimized-images---getimage-and-img-srcset-sizes-\">Responsive, optimized images - <code>getImage()</code> and <code>&#x3C;img srcset sizes /></code></h2>\n<p>Astro provides <a href=\"https://docs.astro.build/en/guides/images/#generating-images-with-getimage\">getImage()</a> function that we will use to optimize images and generate <code>&#x3C;img /></code> tag attributes for the client. It accepts the same arguments as the <code>&#x3C;Image /></code> component. Note, <code>&#x3C;img /></code> tag supports <code>srcset</code> and <code>sizes</code> attributes for responsive images which is sufficient for our use case. This time we don’t need <code>&#x3C;picture /></code> support for different images (art direction) and different formats.</p>\n<p>We will prepare different image presets (sizes) in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/transform.ts#L7\">src/libs/gallery/transform.ts#L7</a>:</p>\n<p>Note that only thumbnail uses responsive image, and lightbox uses a fixed size image since Photoswipe lightbox doesn’t support responsive image (at least without a custom component).</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// common props</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> defaultAstroImageOptions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  format: </span><span style=\"color:#9ECBFF\">'webp'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// thumbnail preset</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> thumbnailImageOptions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  ...</span><span style=\"color:#79B8FF\">IMAGE_SIZES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">RESPONSIVE</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">GALLERY_THUMBNAIL</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// lightbox preset</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> lightboxImageOptions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  ...</span><span style=\"color:#79B8FF\">IMAGE_SIZES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">FIXED</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">MDX_2XL_16_9</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// getImage() wrapper</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getCustomImage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">options</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> UnresolvedImageTransform</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">GetImageResult</span><span style=\"color:#E1E4E8\">> </span><span style=\"color:#F97583\">=></span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  getImage</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    ...</span><span style=\"color:#E1E4E8\">defaultAstroImageOptions,</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    ...</span><span style=\"color:#E1E4E8\">options,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  });</span></span></code></pre>\n<p>After that we use <code>getCustomImage()</code> to optimize gallery images that we previously loaded with <code>import.meta.glob()</code> in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/images.ts#L50\">src/libs/gallery/images.ts#L50</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getGalleryImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> async</span><span style=\"color:#E1E4E8\"> ()</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Promise</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">GalleryImage</span><span style=\"color:#E1E4E8\">[]> </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> thumbnails</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> getCustomImages</span><span style=\"color:#E1E4E8\">(thumbnailImageOptions);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> lightBoxes</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> await</span><span style=\"color:#B392F0\"> getCustomImages</span><span style=\"color:#E1E4E8\">(lightboxImageOptions);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> galleryImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> mergeArrays</span><span style=\"color:#E1E4E8\">(thumbnails, lightBoxes).</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">(([</span><span style=\"color:#FFAB70\">thumbnail</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">lightbox</span><span style=\"color:#E1E4E8\">]) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    thumbnail: </span><span style=\"color:#B392F0\">imageResultToImageAttributes</span><span style=\"color:#E1E4E8\">(thumbnail),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    lightbox: </span><span style=\"color:#B392F0\">imageResultToImageAttributes</span><span style=\"color:#E1E4E8\">(lightbox),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  }));</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> galleryImages;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// select only needed attributes for the &#x3C;img /> tag</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> imageResultToImageAttributes</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">imageResult</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> GetImageResult</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> ImgTagAttributes</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  src: imageResult.src,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  srcSet: imageResult.srcSet?.attribute,</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  ...</span><span style=\"color:#E1E4E8\">imageResult.attributes,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">});</span></span></code></pre>\n<p>Now we have the ready <code>&#x3C;img /></code> attributes (props) available to pass into the React gallery client component.</p>\n<p>Interesting part is configuring <code>&#x3C;img /></code> <code>sizes</code> (<code>sizes</code> and <code>widths</code> args in <code>getImage()</code>) attribute for responsive images in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/constants/image.ts#L86\">src/constants/image.ts#L86</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">GALLERY_THUMBNAIL</span><span style=\"color:#E1E4E8\">: {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  widths</span><span style=\"color:#E1E4E8\">: [</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">XS</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">SM</span><span style=\"color:#E1E4E8\">],</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  sizes</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">`(max-width: ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">SM</span><span style=\"color:#9ECBFF\">}px) ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">SM</span><span style=\"color:#9ECBFF\">}px, ${</span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#79B8FF\">XS</span><span style=\"color:#9ECBFF\">}px`</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">},</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// actual &#x3C;img /> tag attributes that are generated with the GALLERY_THUMBNAIL</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">img</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  sizes</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"(max-width: 640px) 640px, 475px\"</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  srcset</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    /_astro/river16.CcFOUvED_Z2d5kbP.webp 475w, </span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    /_astro/river16.CcFOUvED_Z16pb6L.webp 640w</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">  \"</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  src</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"/_astro/river16.CcFOUvED_Z1Dswo2.webp\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  width</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"4000\"</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  height</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"2252\"</span><span style=\"color:#E1E4E8\"> </span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">/></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/components/react/Gallery.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  id</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#79B8FF\">GALLERY_ID</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"pswp-gallery grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>If you are not familiar with defining responsive images, it’s not that complicated as it seems. The code above basically says, bellow <code>SM</code> screen breakpoint (<code>640px</code>) use <code>SM</code> size (width) (<code>640px</code>) image, and if screen is wider than <code>SM</code> use smaller <code>XS</code> (<code>475px</code>) image. Maybe unexpected to use smaller image for larger screen, but it makes sense when you look at responsive grid that is used for the gallery layout.</p>\n<p>You can see in grid classes that bellow <code>sm:</code> breakpoint image uses full width of the layout and above <code>sm:</code> there are 2 images per row, above <code>lg:</code> 3 images per row, so it makes sense to use the larger image on smaller screens.</p>\n<p>While configuring responsive images it’s  advisable to preview what is generated in the browser and ensure that result meets the expectation, we have sharp images at all resolutions and not too large image files.</p>\n<p>{/* responsive-images-1.5x.webm - <a href=\"https://github.com/user-attachments/assets/48d00b22-feac-40d4-a98b-2e840824fc7f\">https://github.com/user-attachments/assets/48d00b22-feac-40d4-a98b-2e840824fc7f</a> */}\n&#x3C;video {…IMAGE_SIZES.FIXED.MDX_LG} controls>\n<source src=\"{ResponsiveImagesVideo}\" type=\"video/webm\">\n</p>\n<h2 id=\"blur-preloader-css-transition\">Blur preloader, CSS transition</h2>\n<p>Large lightbox image will handle Photoswipe on its own, we won’t interfere with it for now. But we can have some nice effect on thumbnail images on infinite scroll. They are already small enough to load fast so no need to use smaller resolution image for blur preloader, we can achieve the same effect with a simple CSS transition.</p>\n<p>The following code does that <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/components/react/Gallery.tsx#L132\">src/components/react/Gallery.tsx#L132</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> [</span><span style=\"color:#79B8FF\">loadedImages</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">setLoadedImages</span><span style=\"color:#E1E4E8\">] </span><span style=\"color:#F97583\">=</span><span style=\"color:#B392F0\"> useState</span><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#B392F0\">GalleryImage</span><span style=\"color:#E1E4E8\">[]>([]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> isLoadingPageImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> useMemo</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  () </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">Object.</span><span style=\"color:#B392F0\">values</span><span style=\"color:#E1E4E8\">(loadedStates).</span><span style=\"color:#B392F0\">every</span><span style=\"color:#E1E4E8\">(Boolean),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  [loadedStates, loadedImages.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#B392F0\"> callback</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> IntersectionObserverCallback</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">entries</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // must wait here for images to load</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isEnd </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">isLoadingPageImages </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> entries[</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">].isIntersecting) {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      setPage</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">prevPage</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> prevPage </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  </span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // page dependency is important for initial load to work for all resolutions</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}, [observerTarget, page, isEnd, isLoadingPageImages]);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> handleLoad</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">src</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  setLoadedStates</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">prev</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> ({ </span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">prev, [src]: </span><span style=\"color:#79B8FF\">true</span><span style=\"color:#E1E4E8\"> }));</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">{loadedImages.</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">image</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">img</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">image.thumbnail}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      onLoad</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{() </span><span style=\"color:#F97583\">=></span><span style=\"color:#B392F0\"> handleLoad</span><span style=\"color:#E1E4E8\">(image.thumbnail.src)}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      alt</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{loadedStates[image.thumbnail.src] </span><span style=\"color:#F97583\">?</span><span style=\"color:#9ECBFF\"> 'Gallery image'</span><span style=\"color:#F97583\"> :</span><span style=\"color:#9ECBFF\"> ''</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">        'w-full transition-all duration-[2s] ease-in-out'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        loadedStates[image.thumbnail.src]</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">          ?</span><span style=\"color:#9ECBFF\"> 'opacity-100 blur-0 grayscale-0'</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">          :</span><span style=\"color:#9ECBFF\"> 'opacity-75 blur-sm grayscale'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">))}</span></span></code></pre>\n<p>Note that we have a <code>map()</code> call here and we are storing loading states for an array of images. This is because we want to have a smooth transition for the entire new page of images, not for each image separately because they will load randomly and that’s less esthetic. Important part is <code>isLoadingPageImages</code> variable, it is used to block loading a new page until all images from the previous page are loaded. This happens in the observer callback condition <code>if (!isEnd &#x26;&#x26; !isLoadingPageImages &#x26;&#x26; entries[0].isIntersecting)</code>.</p>\n<p>Another part is CSS transition, <code>duration-[...]</code> should be picked so it takes more than actual thumbnail image loading time. For the transition effect, you can play around with opacity and Tailwind’s <a href=\"https://tailwindcss.com/docs/filter\">filter</a> classes and see what looks nicest to you.</p>\n<h2 id=\"infinite-scroll\">Infinite scroll</h2>\n<p>We want to implement pagination through infinite scroll like e.g. Instagram. Obviously, for this, Gallery needs to be a client component and we will use IntersectionObserver to detect the bottom of the gallery and trigger loading a new page of images. For the observer we could use ready-made hooks from utility libraries like <a href=\"https://usehooks.com/useintersectionobserver\">uidotdev/usehooks</a> or <a href=\"https://streamich.github.io/react-use/?path=/story/sensors-useintersection--docs\">streamich/react-use</a> but lets go with our own custom implementation this time.</p>\n<p>The code for this is in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/components/react/Gallery.tsx#L76\">src/components/react/Gallery.tsx#L76</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// sets only page</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#B392F0\"> callback</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> IntersectionObserverCallback</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">entries</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // must wait here for images to load</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#F97583\">!</span><span style=\"color:#E1E4E8\">isEnd </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">isLoadingPageImages </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> entries[</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">].isIntersecting) {</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      setPage</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">prevPage</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> prevPage </span><span style=\"color:#F97583\">+</span><span style=\"color:#79B8FF\"> 1</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    }</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> debouncedCallback</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> debounce</span><span style=\"color:#E1E4E8\">(callback, </span><span style=\"color:#79B8FF\">OBSERVER_DEBOUNCE</span><span style=\"color:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> options</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> IntersectionObserverInit</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> { threshold: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\"> };</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> observer</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> IntersectionObserver</span><span style=\"color:#E1E4E8\">(debouncedCallback, options);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> observerRef</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> observerTarget.current;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  if</span><span style=\"color:#E1E4E8\"> (observerRef) observer.</span><span style=\"color:#B392F0\">observe</span><span style=\"color:#E1E4E8\">(observerRef);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">    if</span><span style=\"color:#E1E4E8\"> (observerRef) observer.</span><span style=\"color:#B392F0\">unobserve</span><span style=\"color:#E1E4E8\">(observerRef);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // page dependency is important for initial load to work for all resolutions</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}, [observerTarget, page, isEnd, isLoadingPageImages]);</span></span></code></pre>\n<p>There are 3 important parts in this code:</p>\n<ol>\n<li>We need to include <code>page</code> state variable in the <code>useEffect</code> dependencies array because we want to trigger effect execution every time new page of images loads and height of gallery increases. Also note that we read <code>page</code> state value from the state setter callback argument <code>setPage((prevPage) => prevPage + 1)</code>, that’s why we must also list <code>page</code> in <code>useEffect</code> dependencies array.</li>\n<li>We need to be precise about when we are loading new page of images. Note this condition <code>if (!isEnd &#x26;&#x26; !isLoadingPageImages &#x26;&#x26; entries[0].isIntersecting)</code>, it practically means “load new page of images whenever 1. we haven’t loaded all images AND 2. previous page of images is fully loaded - for esthetics AND 3. the gallery is scrolled to the bottom - main prerequisite.</li>\n<li>The observer <code>callback()</code> triggers quite often, so we need to limit the frequency by debouncing. Note <code>OBSERVER_DEBOUNCE</code> constant value needs to be fine tuned and validated through practical trial and error.</li>\n</ol>\n<p>Another important and interesting part is detecting bottom of the page and displaying loader UI:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">{</span><span style=\"color:#6A737D\">/* control threshold with margin-top */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">{</span><span style=\"color:#6A737D\">/* must be on top so loader doesn't affect it */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span><span style=\"color:#B392F0\"> ref</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{observerTarget} </span><span style=\"color:#B392F0\">className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"mt-0\"</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  className</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // duration-500 is related to OBSERVER_DEBOUNCE: 300</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">    'flex items-center justify-center transition-all duration-500 ease-in-out'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    shouldShowLoader </span><span style=\"color:#F97583\">?</span><span style=\"color:#9ECBFF\"> 'min-h-48'</span><span style=\"color:#F97583\"> :</span><span style=\"color:#9ECBFF\"> 'min-h-0'</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  )}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {shouldShowLoader </span><span style=\"color:#F97583\">&#x26;&#x26;</span><span style=\"color:#E1E4E8\"> &#x3C;</span><span style=\"color:#79B8FF\">PiSpinnerGapBold</span><span style=\"color:#B392F0\"> className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"size-10 sm:size-12 animate-spin mt-4\"</span><span style=\"color:#E1E4E8\"> />}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>This can be tricky because they are circularly dependent - detection triggers showing loader and displaying loader affects position of detection <code>&#x3C;div ref={observerTarget}/></code>. Another thing ot note is that detection <code>div</code> has zero height and is placed either above or bellow the loader. It is important to be the above loader because we are interested in the bottom of the images, not the loader that will disappear from the UI in a few milliseconds anyway.</p>\n<p>Another important part is controlling and fine-tuning the threshold of the observed element <code>&#x3C;div ref={observerTarget}/></code>. We do this by adjusting the positioning with <code>className=\"mt-0\"</code>, controlling the observers callback execution frequency with <code>OBSERVER_DEBOUNCE</code>, setting the transition timing for the loader element <code>duration-500</code>, specifying how many images we load (number of rows in the gallery) using the <code>pageSize</code> constant, and how many pages of images we load initially on the first screen <code>initialPage</code> constant.</p>\n<p>All of these parameters are connected together and you need to fine tune them for smooth infinite scroll experience. Also note that <code>pageSize</code> and <code>initialPage</code> constants are responsive and need to be defined for each breakpoint independently for full and ergonomic control.</p>\n<p>You can see that in the constants file in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/constants/gallery.ts#L7\">src/constants/gallery.ts#L7</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> GALLERY</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  GALLERY_ID: </span><span style=\"color:#9ECBFF\">'my-gallery'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // Todo: make it responsive</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /** step. */</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  PAGE_SIZE: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    XS: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SM: </span><span style=\"color:#79B8FF\">2</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    LG: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /** page dependency in useEffect is more important. To load first screen quickly, set to 3 pages */</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  INITIAL_PAGE: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    XS: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SM: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    LG: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /** fine tuned for scroll */</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  OBSERVER_DEBOUNCE: </span><span style=\"color:#79B8FF\">300</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">} </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>And the mapping to translate constants into usable <code>pageSize</code> and <code>initialPage</code> values are defined in utility functions in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/utils/gallery.ts#L8\">src/utils/gallery.ts#L8</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">PAGE_SIZE</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">INITIAL_PAGE</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> GALLERY</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// related to gallery grid css</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> breakpointToPageKey</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  XXS: </span><span style=\"color:#9ECBFF\">'XS'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  XS: </span><span style=\"color:#9ECBFF\">'XS'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  SM: </span><span style=\"color:#9ECBFF\">'SM'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  MD: </span><span style=\"color:#9ECBFF\">'SM'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  LG: </span><span style=\"color:#9ECBFF\">'LG'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  XL: </span><span style=\"color:#9ECBFF\">'LG'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  _2XL: </span><span style=\"color:#9ECBFF\">'LG'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">} </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#79B8FF\"> defaultPageKey</span><span style=\"color:#F97583\"> =</span><span style=\"color:#9ECBFF\"> 'LG'</span><span style=\"color:#F97583\"> as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getPageSize</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">breakpoint</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Breakpoint</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> key</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> breakpointToPageKey[breakpoint] </span><span style=\"color:#F97583\">??</span><span style=\"color:#E1E4E8\"> defaultPageKey;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> pageSize</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> PAGE_SIZE</span><span style=\"color:#E1E4E8\">[key];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> pageSize;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#B392F0\"> getInitialPage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span><span style=\"color:#FFAB70\">breakpoint</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> Breakpoint</span><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#F97583\"> =></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> key</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> breakpointToPageKey[breakpoint] </span><span style=\"color:#F97583\">??</span><span style=\"color:#E1E4E8\"> defaultPageKey;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> initialPage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#79B8FF\"> INITIAL_PAGE</span><span style=\"color:#E1E4E8\">[key];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> initialPage;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<p>With this, we have a smooth scrolling experience on all screen sizes:</p>\n<p>{/* infinite-scroll-loader-1.5x.webm - <a href=\"https://github.com/user-attachments/assets/c4deb895-5b48-43d6-ae4e-aa34518fc317\">https://github.com/user-attachments/assets/c4deb895-5b48-43d6-ae4e-aa34518fc317</a> */}\n&#x3C;video {…IMAGE_SIZES.FIXED.MDX_LG} controls>\n<source src=\"{InfiniteScrollLoaderVideo}\" type=\"video/webm\">\n</p>\n<p>Also pay attention how we “fetch” a new page of images to update:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#B392F0\"> fetchImagesUpToPage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  images</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> GalleryImage</span><span style=\"color:#E1E4E8\">[],</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  pageSize</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  nextPage</span><span style=\"color:#F97583\">:</span><span style=\"color:#79B8FF\"> number</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">)</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> GalleryImage</span><span style=\"color:#E1E4E8\">[] </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> endIndex</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> nextPage </span><span style=\"color:#F97583\">*</span><span style=\"color:#E1E4E8\"> pageSize;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> isLastPage</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> endIndex </span><span style=\"color:#F97583\">>=</span><span style=\"color:#E1E4E8\"> images.</span><span style=\"color:#79B8FF\">length</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // for fetchPageImages pagination startIndex must use loadedImages and not all images and page</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> selectedImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> images.</span><span style=\"color:#B392F0\">slice</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#79B8FF\">0</span><span style=\"color:#E1E4E8\">, endIndex);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // load all images for last page</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#F97583\"> !</span><span style=\"color:#E1E4E8\">isLastPage </span><span style=\"color:#F97583\">?</span><span style=\"color:#B392F0\"> sliceToModN</span><span style=\"color:#E1E4E8\">(selectedImages, pageSize) </span><span style=\"color:#F97583\">:</span><span style=\"color:#E1E4E8\"> selectedImages;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// converts page to loaded images</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  const</span><span style=\"color:#79B8FF\"> upToPageImages</span><span style=\"color:#F97583\"> =</span><span style=\"color:#B392F0\"> fetchImagesUpToPage</span><span style=\"color:#E1E4E8\">(images, pageSize, page);</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">  setLoadedImages</span><span style=\"color:#E1E4E8\">(upToPageImages);</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}, [page, images, pageSize]);</span></span></code></pre>\n<p>There are 2 important moments here:</p>\n<ol>\n<li>Since we have a static website all image urls are already included and available on the client so we don’t need to calculate the starting index and can simply use zero <code>images.slice(0, endIndex);</code>. Usually pagination implies a network and database calls that require both <code>startIndex</code> and <code>endIndex</code>, and if we went that path we would need to calculate <code>startIndex</code> by finding the last element of the <code>loadedImages</code> state array in the <code>images</code> array and pass those as arguments.</li>\n<li>Since the <code>pageSize</code> constant is responsive it can change when e.g. user resizes the browser window, so we call <code>sliceToModN(selectedImages, pageSize)</code> for evenly loaded new row. Note that we don’t call this for the last page because, eventually, we want to load all images, and the correct <code>loadedImages</code> array length is important for calculating the <code>isEnd</code> variable.</li>\n</ol>\n<h2 id=\"cumulative-layout-shift\">Cumulative layout shift</h2>\n<p>Layout shift is important web vitals parameter and it’s more challenging to optimize here since we are dealing with a dynamic client components. In the Gallery component we handle this by setting <code>initialPage</code> constant to load enough images to fill the initial gallery screen.</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> GALLERY</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  PAGE_SIZE: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    XS: </span><span style=\"color:#79B8FF\">1</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SM: </span><span style=\"color:#79B8FF\">2</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    LG: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  INITIAL_PAGE: {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    XS: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    SM: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    LG: </span><span style=\"color:#79B8FF\">3</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">} </span><span style=\"color:#F97583\">as</span><span style=\"color:#F97583\"> const</span><span style=\"color:#E1E4E8\">;</span></span></code></pre>\n<p>Another optimization we can do is to stretch the empty gallery container element with <code>flex grow</code>. For that we need to modify the Page layout and pass the required Tailwind classes via the MDX frontmatter and <code>articleClass</code> prop.</p>\n<p>You can see that in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/layouts/Page.astro#L38\">src/layouts/Page.astro#L38</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"astro\"><code><span class=\"line\"><span style=\"color:#6A737D\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> Centered </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/layouts/Centered.astro'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { getOpenGraphImagePath } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/libs/api/open-graph/image-path'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> { cn } </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '@/utils/styles'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> interface</span><span style=\"color:#B392F0\"> Content</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  class</span><span style=\"color:#F97583\">?:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  /** for flex flex-grow min-height to prevent layout shift for client components */</span></span>\n<span class=\"line\"><span style=\"color:#FFAB70\">  articleClass</span><span style=\"color:#F97583\">?:</span><span style=\"color:#79B8FF\"> string</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">const</span><span style=\"color:#E1E4E8\"> { </span><span style=\"color:#79B8FF\">title</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">description</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#FFAB70\">class</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#79B8FF\">className</span><span style=\"color:#E1E4E8\">, </span><span style=\"color:#79B8FF\">articleClass</span><span style=\"color:#E1E4E8\"> } </span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\"> content;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// ...</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">Centered</span><span style=\"color:#E1E4E8\"> {metadata} </span><span style=\"color:#B392F0\">class</span><span style=\"color:#E1E4E8\">={</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(className)}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {</span><span style=\"color:#6A737D\">/* in general must not have flex, it will disable margin collapsing in MDX */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;</span><span style=\"color:#85E89D\">article</span><span style=\"color:#B392F0\"> class</span><span style=\"color:#E1E4E8\">={</span><span style=\"color:#B392F0\">cn</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'my-prose'</span><span style=\"color:#E1E4E8\">, articleClass)}></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">slot</span><span style=\"color:#E1E4E8\"> /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;/</span><span style=\"color:#85E89D\">article</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;/</span><span style=\"color:#79B8FF\">Centered</span><span style=\"color:#E1E4E8\">></span></span></code></pre>\n<p>Flex class is passed from MDX frontmatter in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/pages/gallery.mdx#L7\">src/pages/gallery.mdx#L7</a>:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"mdx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">---</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">layout</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'../layouts/Page.astro'</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">...</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">class</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'max-w-5xl'</span></span>\n<span class=\"line\"><span style=\"color:#85E89D\">articleClass</span><span style=\"color:#E1E4E8\">: </span><span style=\"color:#9ECBFF\">'grow flex flex-col'</span></span>\n<span class=\"line\"><span style=\"color:#9ECBFF\">---</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">import</span><span style=\"color:#E1E4E8\"> Gallery </span><span style=\"color:#F97583\">from</span><span style=\"color:#9ECBFF\"> '../components/Gallery.astro'</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#79B8FF;font-weight:bold\"># Gallery</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">&#x3C;</span><span style=\"color:#79B8FF\">Gallery</span><span style=\"color:#B392F0\"> class</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"not-prose grow\"</span><span style=\"color:#E1E4E8\"> /></span></span></code></pre>\n<p>This will reduce the shift of DOM elements size, it won’t make it perfect like in fully static page but for our use case it’s good enough.</p>\n<p>Another point to make is that <code>flex</code> container will disable margin collapsing which is important for proper vertical spacings in MDX generated HTML. So if you do that you will need to add an additional <code>&#x3C;div></code> wrapper element without flex to re-enable proper margin collapsing.</p>\n<p><strong>Lighthouse score, old gallery:</strong></p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={LayoutShiftBeforeImage} alt=“Old gallery Lighthouse score” /></p>\n<p><strong>Lighthouse score, new gallery:</strong></p>\n<p>&#x3C;Image {…IMAGE_SIZES.FIXED.MDX_MD} src={LayoutShiftAfterImage} alt=“New gallery Lighthouse score” /></p>\n<p>Please ignore the “Accessibility” score above, since the accessibility attributes aren’t yet tackled on the entire website.</p>\n<h2 id=\"lightbox-with-photoswipe\">Lightbox with Photoswipe</h2>\n<p>For previewing images in full screen lightbox we will use ready made library <a href=\"https://github.com/dimsemenov/photoswipe\">Photoswipe</a> that looks solid, reliable and flexible. We will use a basic <a href=\"https://photoswipe.com/react-image-gallery/\">React example</a> from the documentation.</p>\n<p>This is the code <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/components/react/Gallery.tsx#L98\">src/components/react/Gallery.tsx#L98</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"tsx\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// lightbox</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">useEffect</span><span style=\"color:#E1E4E8\">(() </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  let</span><span style=\"color:#E1E4E8\"> lightbox</span><span style=\"color:#F97583\">:</span><span style=\"color:#B392F0\"> PhotoSwipeLightbox</span><span style=\"color:#F97583\"> |</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#F97583\"> =</span><span style=\"color:#F97583\"> new</span><span style=\"color:#B392F0\"> PhotoSwipeLightbox</span><span style=\"color:#E1E4E8\">({</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    gallery: </span><span style=\"color:#9ECBFF\">'#'</span><span style=\"color:#F97583\"> +</span><span style=\"color:#79B8FF\"> GALLERY_ID</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    children: </span><span style=\"color:#9ECBFF\">'a'</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">    pswpModule</span><span style=\"color:#E1E4E8\">: () </span><span style=\"color:#F97583\">=></span><span style=\"color:#F97583\"> import</span><span style=\"color:#E1E4E8\">(</span><span style=\"color:#9ECBFF\">'photoswipe'</span><span style=\"color:#E1E4E8\">),</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  });</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  lightbox.</span><span style=\"color:#B392F0\">init</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">  return</span><span style=\"color:#E1E4E8\"> () </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    lightbox?.</span><span style=\"color:#B392F0\">destroy</span><span style=\"color:#E1E4E8\">();</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    lightbox </span><span style=\"color:#F97583\">=</span><span style=\"color:#79B8FF\"> null</span><span style=\"color:#E1E4E8\">;</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  };</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">}, []);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">return</span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;</span><span style=\"color:#85E89D\">div</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      id</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#79B8FF\">GALLERY_ID</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">      className</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"pswp-gallery grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    ></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      {loadedImages.</span><span style=\"color:#B392F0\">map</span><span style=\"color:#E1E4E8\">((</span><span style=\"color:#FFAB70\">image</span><span style=\"color:#E1E4E8\">) </span><span style=\"color:#F97583\">=></span><span style=\"color:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;</span><span style=\"color:#85E89D\">a</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          key</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{</span><span style=\"color:#9ECBFF\">`${</span><span style=\"color:#79B8FF\">GALLERY_ID</span><span style=\"color:#9ECBFF\">}--${</span><span style=\"color:#E1E4E8\">image</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">lightbox</span><span style=\"color:#9ECBFF\">.</span><span style=\"color:#E1E4E8\">src</span><span style=\"color:#9ECBFF\">}`</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">          // lightbox doesn't support responsive image</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          href</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{image.lightbox.src}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          data-pswp-width</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{image.lightbox.width}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          data-pswp-height</span><span style=\"color:#F97583\">=</span><span style=\"color:#E1E4E8\">{image.lightbox.height}</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          target</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"_blank\"</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">          rel</span><span style=\"color:#F97583\">=</span><span style=\"color:#9ECBFF\">\"noreferrer\"</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        ></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          &#x3C;</span><span style=\"color:#85E89D\">img</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">              {</span><span style=\"color:#F97583\">...</span><span style=\"color:#E1E4E8\">image.thumbnail}</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">            // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">          /></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">        &#x3C;/</span><span style=\"color:#85E89D\">a</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">      ))}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    &#x3C;/</span><span style=\"color:#85E89D\">div</span><span style=\"color:#E1E4E8\">></span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  {</span><span style=\"color:#6A737D\">/* ... */</span><span style=\"color:#E1E4E8\">}</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  &#x3C;></span></span></code></pre>\n<p>Note that for a simplicity sake we are using a simple fixed image and Photoswipe implements scale transition on its own. By default it uses a simple link <code>&#x3C;a href={image.lightbox.src}></code> to load the <code>&#x3C;img src /></code> in the full page lightbox.</p>\n<p>This is a tradeoff for simplicity. Loading a responsive image with <code>srcset</code> would require integrating a custom component which could be a topic for another article. Another possible improvement is to enable closing lightbox on backdrop click on mobile which is not the case with the default config.</p>\n<p>Lightbox image size is defined in <a href=\"https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/transform.ts#L24\">src/libs/gallery/transform.ts#L24</a></p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"ts\"><code><span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> lightboxImageOptions</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#F97583\">  ...</span><span style=\"color:#79B8FF\">IMAGE_SIZES</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">FIXED</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">MDX_2XL_16_9</span><span style=\"color:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\">// src/constants/image.ts</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#F97583\">export</span><span style=\"color:#F97583\"> const</span><span style=\"color:#79B8FF\"> IMAGE_SIZES</span><span style=\"color:#F97583\"> =</span><span style=\"color:#E1E4E8\"> {</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  FIXED: {</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">    // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">    MDX_2XL_16_9: { width: </span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">._2XL, height: </span><span style=\"color:#79B8FF\">TW_SCREENS</span><span style=\"color:#E1E4E8\">.</span><span style=\"color:#79B8FF\">HEIGHTS</span><span style=\"color:#E1E4E8\">._2XL },</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">  },</span></span>\n<span class=\"line\"><span style=\"color:#6A737D\">  // ...</span></span>\n<span class=\"line\"><span style=\"color:#E1E4E8\">};</span></span></code></pre>\n<h2 id=\"completed-code-and-demo\">Completed code and demo</h2>\n<ul>\n<li><strong>Demo:</strong> <a href=\"https://nemanjamitic.com/gallery\">https://nemanjamitic.com/gallery</a></li>\n<li><strong>Github repository:</strong> <a href=\"https://github.com/nemanjam/nemanjam.github.io\">https://github.com/nemanjam/nemanjam.github.io</a></li>\n</ul>\n<p>The relevant files:</p>\n<pre class=\"astro-code github-dark\" style=\"background-color:#24292e;color:#e1e4e8; overflow-x: auto;\" tabindex=\"0\" data-language=\"bash\"><code><span class=\"line\"><span style=\"color:#6A737D\"># new gallery https://github.com/nemanjam/nemanjam.github.io/tree/c1e105847d8e7b4ab4aaffad3078726c37f67528</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> c1e105847d8e7b4ab4aaffad3078726c37f67528</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/pages/gallery.mdx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/Gallery.astro</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/react/Gallery.tsx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/libs/gallery/images.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/libs/gallery/transform.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/utils/gallery.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/constants/gallery.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/constants/image.ts</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/react/hooks/useScrollDown.tsx</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">src/components/react/hooks/useWidth.tsx</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6A737D\"># old gallery https://github.com/nemanjam/nemanjam.github.io/tree/e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae</span></span>\n<span class=\"line\"><span style=\"color:#B392F0\">git</span><span style=\"color:#9ECBFF\"> checkout</span><span style=\"color:#9ECBFF\"> e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae</span></span></code></pre>\n<h2 id=\"outro\">Outro</h2>\n<p>That was a pretty long read, thank you for your attention and dedication. Have you implemented an Astro image gallery yourself and used a different approach? Do you have suggestions for improvements or spotted anything incorrect? Don’t hesitate to leave a comment below.</p>\n<h2 id=\"references\">References</h2>\n<ul>\n<li>Astro gallery example, inspiration to take Photoswipe for a lightbox component <a href=\"https://github.com/EmaSuriano/astro-art-portfolio\">https://github.com/EmaSuriano/astro-art-portfolio</a></li>\n<li>Photoswipe documentation <a href=\"https://photoswipe.com/getting-started\">https://photoswipe.com/getting-started</a></li>\n<li>Astro documentation, tutorial how to use <code>getImage()</code> function <a href=\"https://docs.astro.build/en/recipes/build-custom-img-component/\">https://docs.astro.build/en/recipes/build-custom-img-component/</a></li>\n<li>Infinite scroll with React and IntersectionObserver tutorial <a href=\"https://blog.logrocket.com/react-infinite-scroll/\">https://blog.logrocket.com/react-infinite-scroll/</a> and Codesandbox example <a href=\"https://codesandbox.io/p/github/Elijah-trillionz/react-infinite-scroll/master\">https://codesandbox.io/p/github/Elijah-trillionz/react-infinite-scroll/master</a></li>\n<li>Images in Astro as client components, useful Reddit discussion <a href=\"https://www.reddit.com/r/astrojs/comments/1bia6lq/how_to_utilize_image_with_react_component\">https://www.reddit.com/r/astrojs/comments/1bia6lq/how_to_utilize_image_with_react_component</a></li>\n</ul>",
            "url": "https://docker.nemanjamitic.com/blog/2025-04-02-astro-react-gallery/",
            "title": "Build an image gallery with Astro and React",
            "summary": "Learn through a practical example how to build a performant, responsive image gallery with Astro and React.\n",
            "date_modified": "2025-04-02T00:00:00.000Z",
            "date_published": "2025-04-02T00:00:00.000Z",
            "author": {
                "name": "Nemanja Mitic",
                "url": "https://docker.nemanjamitic.com/about/"
            }
        }
    ]
}