<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yaroslav</title>
    <description>The latest articles on DEV Community by Yaroslav (@yognevoy).</description>
    <link>https://dev.to/yognevoy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4076118%2Fcb78f9ec-91fb-4da6-b50a-07d9fa376802.png</url>
      <title>DEV Community: Yaroslav</title>
      <link>https://dev.to/yognevoy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yognevoy"/>
    <language>en</language>
    <item>
      <title>Migrating from Caddy + PHP-FPM to FrankenPHP and Laravel Octane</title>
      <dc:creator>Yaroslav</dc:creator>
      <pubDate>Tue, 13 Oct 2026 09:10:04 +0000</pubDate>
      <link>https://dev.to/yognevoy/migrating-from-caddy-php-fpm-to-frankenphp-and-laravel-octane-51ge</link>
      <guid>https://dev.to/yognevoy/migrating-from-caddy-php-fpm-to-frankenphp-and-laravel-octane-51ge</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; a production stack moved off Caddy and php-fpm onto FrankenPHP running under Laravel Octane's worker mode, cutting the framework's full per-request boot cost.&lt;/p&gt;

&lt;p&gt;Laravel, like most PHP frameworks, carries real bootstrap overhead. Every single request builds the whole framework from a cold start. I ran into this myself in my own project - even small requests, like opening the homepage or the policy page, were taking noticeable time and eating CPU. It's something a user notices while using the app.&lt;/p&gt;

&lt;p&gt;I'd heard about &lt;a href="https://frankenphp.dev" rel="noopener noreferrer"&gt;FrankenPHP&lt;/a&gt; before - a single Go binary with a PHP runtime built into Caddy. I decided to give it a try and rework my app to run on it, just to speed things up.&lt;/p&gt;

&lt;p&gt;The stack: Laravel with an Inertia/Vue frontend, Postgres, and a Go binary for PDF generation - all on one small VM (2 vCPU, 2GB RAM), running entirely in Docker via docker compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Replacing Caddy and php-fpm with FrankenPHP
&lt;/h2&gt;

&lt;p&gt;Previously, this meant two images: caddy and php-fpm. Now both get replaced with a single frankenphp image, which handles TLS and PHP execution all on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dockerfile&lt;/strong&gt;, before (two targets, two images):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;php:8.4-fpm-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;php-fpm&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; postgresql-libs libzip icu-libs oniguruma &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="nt"&gt;--virtual&lt;/span&gt; .build-deps &lt;span class="nv"&gt;$PHPIZE_DEPS&lt;/span&gt; postgresql-dev libzip-dev icu-dev oniguruma-dev &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker-php-ext-install &lt;span class="nt"&gt;-j&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;nproc&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; pdo pdo_pgsql mbstring bcmath zip intl opcache &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apk del .build-deps
&lt;span class="c"&gt;# ...COPY app code, entrypoint, USER www-data...&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["php-fpm"]&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;caddy:2-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;caddy&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; Caddyfile /etc/caddy/Caddyfile&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=php-fpm /var/www/html/public /var/www/html/public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After (one target):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;dunglas/frankenphp:1-php8.4-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;frankenphp&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; postgresql-libs libzip icu-libs oniguruma &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="nt"&gt;--virtual&lt;/span&gt; .build-deps &lt;span class="nv"&gt;$PHPIZE_DEPS&lt;/span&gt; postgresql-dev libzip-dev icu-dev oniguruma-dev libcap &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker-php-ext-install &lt;span class="nt"&gt;-j&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;nproc&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; pdo pdo_pgsql mbstring bcmath zip intl opcache &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; setcap &lt;span class="nv"&gt;cap_net_bind_service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;+ep &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which frankenphp&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apk del .build-deps

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /var/www/html&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=composer-deps /app/vendor ./vendor&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=node-build /app/public/build ./public/build&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; Caddyfile /etc/frankenphp/Caddyfile&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;php artisan storage:link &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www-data:www-data /var/www/html /data /config

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; www-data&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FrankenPHP's image runs as &lt;code&gt;root&lt;/code&gt; by default, since binding ports 80/443 usually needs it. To keep the same &lt;code&gt;www-data&lt;/code&gt; user the php-fpm image used, I grant the binary one narrow capability (&lt;code&gt;cap_net_bind_service&lt;/code&gt;) instead of running the whole process as root.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caddyfile&lt;/strong&gt;, before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;root&lt;/span&gt; &lt;span class="s"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/var/www/html/public&lt;/span&gt;
    &lt;span class="s"&gt;encode&lt;/span&gt; &lt;span class="s"&gt;gzip&lt;/span&gt;
    &lt;span class="s"&gt;php_fastcgi&lt;/span&gt; &lt;span class="nf"&gt;php-fpm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;
    &lt;span class="s"&gt;file_server&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After - one &lt;a href="https://caddyserver.com/docs/caddyfile/directives/php_server" rel="noopener noreferrer"&gt;&lt;code&gt;php_server&lt;/code&gt;&lt;/a&gt; directive replaces both &lt;code&gt;php_fastcgi&lt;/code&gt; and &lt;code&gt;file_server&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;root&lt;/span&gt; &lt;span class="s"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/var/www/html/public&lt;/span&gt;
    &lt;span class="s"&gt;encode&lt;/span&gt; &lt;span class="s"&gt;zstd&lt;/span&gt; &lt;span class="s"&gt;gzip&lt;/span&gt;
    &lt;span class="s"&gt;php_server&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;docker-compose.prod.yml&lt;/strong&gt; shrank from two services down to one &lt;code&gt;app&lt;/code&gt; service.&lt;/p&gt;

&lt;h3&gt;
  
  
  A production permissions issue
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;caddy_data&lt;/code&gt;/&lt;code&gt;caddy_config&lt;/code&gt; volumes already existed on the server, written by the old Caddy container running as root. The new &lt;code&gt;app&lt;/code&gt; container runs as &lt;code&gt;www-data&lt;/code&gt; - it couldn't read its own TLS certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"error":"open /data/caddy/certificates/.../app.example.com.key: permission denied"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare saw a failed TLS handshake and returned &lt;code&gt;525&lt;/code&gt; to visitors. The container couldn't start until those permissions were fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; a one-time ownership correction and an explicit restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env.production run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; root app &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www-data:www-data /data /config

docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.prod.yml &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env.production restart app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: adding Laravel Octane worker mode
&lt;/h2&gt;

&lt;p&gt;Classic FrankenPHP still boots the entire Laravel framework from zero on every request. It's a faster web server, since it skips the FastCGI hop to php-fpm, but the request lifecycle stays the same. Worker mode helps with that - it keeps one PHP process alive, working through many requests in a loop, instead of exiting and restarting for each one.&lt;/p&gt;

&lt;p&gt;Laravel doesn't support that mode by default, so there's &lt;a href="https://laravel.com/docs/octane" rel="noopener noreferrer"&gt;Laravel Octane&lt;/a&gt; - a package that lets the framework run as a long-living process, usable with FrankenPHP, Swoole, or RoadRunner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require laravel/octane
php artisan octane:install &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;frankenphp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates &lt;code&gt;config/octane.php&lt;/code&gt; and &lt;code&gt;public/frankenphp-worker.php&lt;/code&gt; - the script FrankenPHP keeps running, handling request after request without rebooting the app in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caddyfile&lt;/strong&gt;, the final version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$LETSENCRYPT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;frankenphp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;worker&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;file&lt;/span&gt; &lt;span class="n"&gt;/var/www/html/public/frankenphp-worker.php&lt;/span&gt;
        &lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="s"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/var/www/html/public&lt;/span&gt;
    &lt;span class="s"&gt;encode&lt;/span&gt; &lt;span class="s"&gt;zstd&lt;/span&gt; &lt;span class="s"&gt;br&lt;/span&gt; &lt;span class="s"&gt;gzip&lt;/span&gt;

    &lt;span class="s"&gt;php_server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;frankenphp-worker.php&lt;/span&gt;
        &lt;span class="s"&gt;try_files&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;path&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="s"&gt;frankenphp-worker.php&lt;/span&gt;
        &lt;span class="s"&gt;resolve_root_symlink&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A shared state problem
&lt;/h3&gt;

&lt;p&gt;Since the framework core and the project's code now load only once at startup, that same booted instance gets reused across thousands of requests, so any user state the code keeps won't get cleared between requests.&lt;/p&gt;

&lt;p&gt;A short checklist for the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No mutable &lt;code&gt;static&lt;/code&gt; properties anywhere in the application code.&lt;/li&gt;
&lt;li&gt;No data stored in global variables (&lt;code&gt;$GLOBALS&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Nothing calls &lt;code&gt;config([...])&lt;/code&gt; at runtime - the main way config leaks between requests under Octane.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After that, it's worth testing the app under concurrent use from different users, and confirm none of them ever saw another's session, auth state, or data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;After deploy, I ran a &lt;a href="https://pagespeed.web.dev" rel="noopener noreferrer"&gt;PageSpeed Insights&lt;/a&gt; test on the live site. It came back with &lt;strong&gt;100/100 performance&lt;/strong&gt; on desktop, 0.5s LCP, 0ms total blocking time.&lt;/p&gt;

&lt;p&gt;In production, the startup log confirms the worker is actually running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"logger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"frankenphp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"FrankenPHP started 🐘"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total_threads"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"worker_threads"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"max_threads"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;By the end, the project got migrated to a single frankenphp container running in worker mode, one process that stays loaded instead of rebuilding itself on every request.&lt;/p&gt;

&lt;p&gt;It made a difference to the site's performance, and feels quite satisfying.&lt;/p&gt;

&lt;p&gt;You can see the project here: &lt;a href="https://getyourcv.app" rel="noopener noreferrer"&gt;https://getyourcv.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>laravel</category>
      <category>performance</category>
      <category>php</category>
    </item>
    <item>
      <title>Setting up nginx as a reverse proxy</title>
      <dc:creator>Yaroslav</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:39:54 +0000</pubDate>
      <link>https://dev.to/yognevoy/setting-up-nginx-as-a-reverse-proxy-4ade</link>
      <guid>https://dev.to/yognevoy/setting-up-nginx-as-a-reverse-proxy-4ade</guid>
      <description>&lt;p&gt;If you run more than one service on a server, you need a single entry point in front of them. Instead of setting up a web server and SSL inside every service, it gets handled once, in one place.&lt;/p&gt;

&lt;p&gt;We will use &lt;a href="https://github.com/nginx-proxy/nginx-proxy" rel="noopener noreferrer"&gt;jwilder/nginx-proxy&lt;/a&gt;. It watches the Docker socket and builds nginx config automatically, based on labels and env vars on your containers. No manual nginx.conf editing every time you add a service.&lt;/p&gt;

&lt;p&gt;Prerequisites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A server with Docker and the Docker Compose plugin installed.&lt;/li&gt;
&lt;li&gt;A domain name pointed at the server (needed later for SSL).&lt;/li&gt;
&lt;li&gt;Basic comfort with docker compose files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Base setup: nginx-proxy
&lt;/h2&gt;

&lt;p&gt;The core piece is the nginx-proxy container itself. It exposes ports 80 and 443, and reads the Docker socket to see which containers are running. When a container starts or stops, it updates the routing on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx-proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxproxy/nginx-proxy&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx_proxy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;html:/usr/share/nginx/html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;certs_volume:/etc/nginx/certs:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/tmp/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/nginx-vhosts:/etc/nginx/vhost.d:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webproxy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docker socket mount lets nginx-proxy see new containers as they appear. The vhost.d mount is a folder for custom nginx snippets per domain, we will use this later to lock down some endpoints from public access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding HTTPS with acme-companion
&lt;/h2&gt;

&lt;p&gt;Plain HTTP is not enough for anything public, so we add &lt;a href="https://github.com/nginx-proxy/acme-companion" rel="noopener noreferrer"&gt;acme-companion&lt;/a&gt;, which pairs with nginx-proxy and handles Let's Encrypt certificates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;acme-companion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxproxy/acme-companion&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme_companion&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DEFAULT_EMAIL=${EMAIL}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;certs_volume:/etc/nginx/certs:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;acme:/etc/acme.sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
    &lt;span class="na"&gt;volumes_from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nginx-proxy&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webproxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It shares the certs volume with nginx-proxy through &lt;code&gt;volumes_from&lt;/code&gt;, so both containers see the same certificate files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full docker-compose.yml
&lt;/h2&gt;

&lt;p&gt;Let's put both containers, volumes, and networks together:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx-proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxproxy/nginx-proxy&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx_proxy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;html:/usr/share/nginx/html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;certs_volume:/etc/nginx/certs:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/tmp/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/nginx-vhosts:/etc/nginx/vhost.d:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webproxy&lt;/span&gt;

  &lt;span class="na"&gt;acme-companion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxproxy/acme-companion&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme_companion&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DEFAULT_EMAIL=${EMAIL}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;certs_volume:/etc/nginx/certs:rw&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;acme:/etc/acme.sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
    &lt;span class="na"&gt;volumes_from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nginx-proxy&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webproxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;certs_volume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;webproxy&lt;/code&gt; network is marked external, meaning it is not created by this compose file. We create it once by hand, and every service that wants to sit behind the proxy joins this same network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;.env:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;admin@example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EMAIL&lt;/code&gt; is used by acme-companion as the default contact address for Let's Encrypt. Fill it in with a real address, because Let's Encrypt sends expiry warnings there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the network
&lt;/h2&gt;

&lt;p&gt;Since the network is external, create it before the first deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create webproxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this once per server. Every stack that needs to be reachable through the proxy connects to this network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the vhost directory
&lt;/h2&gt;

&lt;p&gt;nginx-proxy mounts &lt;code&gt;/srv/nginx-vhosts&lt;/code&gt; as a read only folder for per-domain nginx snippets. Services publish their own config fragments here, e.g. to restrict access to internal routes. Create the folder and give your user ownership:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /srv/nginx-vhosts
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;:&lt;span class="nv"&gt;$USER&lt;/span&gt; /srv/nginx-vhosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Connecting a service to the proxy
&lt;/h2&gt;

&lt;p&gt;Any container can join the proxy without touching nginx directly. Add a few env vars and put the container on the &lt;code&gt;webproxy&lt;/code&gt; network.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;myapp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VIRTUAL_HOST=${DOMAIN}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VIRTUAL_PORT=8000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LETSENCRYPT_HOST=${DOMAIN}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LETSENCRYPT_EMAIL=${EMAIL}&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webproxy&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VIRTUAL_HOST&lt;/code&gt; tells nginx-proxy which domain routes to this container. &lt;code&gt;VIRTUAL_PORT&lt;/code&gt; is the port your app listens on inside the container. &lt;code&gt;LETSENCRYPT_HOST&lt;/code&gt; and &lt;code&gt;LETSENCRYPT_EMAIL&lt;/code&gt; tell acme-companion to request a certificate for that domain.&lt;/p&gt;

&lt;p&gt;.env for the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DOMAIN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="py"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;admin@example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;localhost&lt;/code&gt; with your real domain before deploying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom nginx config for a service
&lt;/h2&gt;

&lt;p&gt;Sometimes the default routing is not enough. A route needs an IP allow list, custom headers, or something else nginx-proxy does not expose through env vars. For this, place a config snippet into /srv/nginx-vhosts/${DOMAIN}, and nginx-proxy loads it automatically for that domain.&lt;/p&gt;

&lt;p&gt;A generic template, filled in by a deploy script using &lt;code&gt;envsubst&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/internal/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt;         &lt;span class="s"&gt;http://&lt;/span&gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;DOMAIN&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;Host&lt;/span&gt;              &lt;span class="nv"&gt;$http_host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Real-IP&lt;/span&gt;         &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;   &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-Port&lt;/span&gt;  &lt;span class="nv"&gt;$server_port&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, only requests from the allowed range reach the route, everything else gets a 403. For a full example of a deploy script that fills in a template like this, see &lt;a href="https://dev.to/yognevoy/setting-up-keycloak-as-a-central-identity-provider-4349"&gt;this article&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;We now have one nginx-proxy container in front of everything, and it picks up new services on its own, no manual nginx edits needed. Certificates renew automatically through acme-companion, and any route that needs special rules gets its own config. From here, any service just joins the webproxy network and gets a domain and SSL for free.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Setting up Keycloak as a central identity provider</title>
      <dc:creator>Yaroslav</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:22:50 +0000</pubDate>
      <link>https://dev.to/yognevoy/setting-up-keycloak-as-a-central-identity-provider-4349</link>
      <guid>https://dev.to/yognevoy/setting-up-keycloak-as-a-central-identity-provider-4349</guid>
      <description>&lt;p&gt;This is the first article in a series about Keycloak. We will set up Keycloak as an authentication provider and later connect it to internal services. In the end we get a single entry point and a single user provider for all of them.&lt;/p&gt;

&lt;p&gt;In this article we set up Keycloak itself, get the admin console running, and lock it down properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;We run Keycloak with Docker, behind an existing nginx reverse proxy. This guide assumes such a proxy (e.g. &lt;a href="https://github.com/nginx-proxy/nginx-proxy" rel="noopener noreferrer"&gt;jwilder/nginx-proxy&lt;/a&gt; with an &lt;a href="https://github.com/nginx-proxy/acme-companion" rel="noopener noreferrer"&gt;ACME companion&lt;/a&gt; for SSL) is already running on the host and shared across services, listening on the &lt;code&gt;webproxy&lt;/code&gt; docker network.&lt;/p&gt;

&lt;p&gt;Make sure Docker and the Docker Compose plugin are installed on the server first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If these commands fail, install Docker using the official instructions for your OS. Once Docker is ready, create a folder on the server for the project, e.g. /opt/docker.keycloak. We will place the files there step by step: docker-compose.yml, .env, nginx/vhost.conf, and deploy.sh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;docker-compose.yml:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;keycloak&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/keycloak/keycloak:26.2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jdbc:postgresql://postgres:5432/keycloak&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_USERNAME:-keycloak}&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;KC_BOOTSTRAP_ADMIN_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KEYCLOAK_ADMIN:-admin}&lt;/span&gt;
      &lt;span class="na"&gt;KC_BOOTSTRAP_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KEYCLOAK_ADMIN_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;KC_HOSTNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DOMAIN}&lt;/span&gt;
      &lt;span class="na"&gt;KC_HTTP_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;KC_PROXY_HEADERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xforwarded&lt;/span&gt;
      &lt;span class="na"&gt;KC_HOSTNAME_STRICT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
      &lt;span class="na"&gt;KC_HEALTH_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;JAVA_OPTS_APPEND&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_JAVA_OPTS:--Xms512m -Xmx512m}&lt;/span&gt;
      &lt;span class="na"&gt;VIRTUAL_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DOMAIN}&lt;/span&gt;
      &lt;span class="na"&gt;VIRTUAL_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;LETSENCRYPT_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DOMAIN}&lt;/span&gt;
      &lt;span class="na"&gt;LETSENCRYPT_EMAIL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${EMAIL}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;webproxy&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&amp;lt;&amp;gt;/dev/tcp/127.0.0.1/9000&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;printf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'GET&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/health/ready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HTTP/1.1&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;Host:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s"&gt;Connection:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="se"&gt;\r\n\r\n&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;amp;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cat&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;amp;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;grep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;UP&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;'"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;90s&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1g&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak_postgres&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_USERNAME:-keycloak}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;keycloak_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$$POSTGRES_USER&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$$POSTGRES_DB"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512m&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;keycloak&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
  &lt;span class="na"&gt;webproxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;keycloak_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The setup has two containers: Keycloak and Postgres. Keycloak stores realm and user data in Postgres, so the database needs to be healthy before Keycloak starts. The &lt;code&gt;webproxy&lt;/code&gt; network connects Keycloak to an external nginx reverse proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;.env:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DOMAIN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="py"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;admin@example.com&lt;/span&gt;

&lt;span class="py"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;
&lt;span class="py"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;

&lt;span class="py"&gt;KC_DB_USERNAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;keycloak&lt;/span&gt;
&lt;span class="py"&gt;KC_DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DOMAIN&lt;/code&gt;: the domain for Keycloak (e.g. &lt;code&gt;auth.example.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EMAIL&lt;/code&gt;: email for the Let's Encrypt SSL certificate&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KEYCLOAK_ADMIN&lt;/code&gt;: Keycloak admin username&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/code&gt;: admin password&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KC_DB_USERNAME&lt;/code&gt;: Postgres username (default &lt;code&gt;keycloak&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KC_DB_PASSWORD&lt;/code&gt;: Postgres password&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fill in &lt;code&gt;DOMAIN&lt;/code&gt;, &lt;code&gt;EMAIL&lt;/code&gt;, and both passwords before the first run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restricting admin access
&lt;/h2&gt;

&lt;p&gt;The admin console and the master realm give full control over Keycloak. If this is open to the web, it becomes a direct target for brute force and credential stuffing. The safe option is to close it from the outside and allow access only from &lt;code&gt;127.0.0.1&lt;/code&gt;, then reach it through an SSH tunnel when needed.&lt;/p&gt;

&lt;p&gt;nginx/vhost.conf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Keycloak admin access restriction.&lt;/span&gt;
&lt;span class="c1"&gt;# Template: ${DOMAIN} is substituted by deploy.sh via envsubst.&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# Admin interface is accessible only via SSH tunnel from localhost.&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/auth/admin/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt;         &lt;span class="s"&gt;http://&lt;/span&gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;DOMAIN&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;Host&lt;/span&gt;              &lt;span class="nv"&gt;$http_host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Real-IP&lt;/span&gt;         &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;   &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-Port&lt;/span&gt;  &lt;span class="nv"&gt;$server_port&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;Proxy&lt;/span&gt;             &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/auth/realms/master/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt;         &lt;span class="s"&gt;http://&lt;/span&gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;DOMAIN&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;Host&lt;/span&gt;              &lt;span class="nv"&gt;$http_host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Real-IP&lt;/span&gt;         &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;   &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;X-Forwarded-Port&lt;/span&gt;  &lt;span class="nv"&gt;$server_port&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt;   &lt;span class="s"&gt;Proxy&lt;/span&gt;             &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This config blocks the admin console and the master realm from the public internet. Only requests from &lt;code&gt;127.0.0.1&lt;/code&gt; get through. This means the admin panel is reachable only through an SSH tunnel, which we set up below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;With the config files ready, one script publishes the nginx vhost and starts Keycloak.&lt;/p&gt;

&lt;p&gt;deploy.sh:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;VHOSTS_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/srv/nginx-vhosts"&lt;/span&gt;

&lt;span class="c"&gt;# Load .env&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; +a

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error: DOMAIN is not set in .env"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Publish nginx vhost config&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Publishing nginx config for &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VHOSTS_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
envsubst &lt;span class="s1"&gt;'${DOMAIN}'&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/nginx/vhost.conf"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VHOSTS_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Reload nginx&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;docker inspect nginx_proxy &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;nginx_proxy nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"nginx reloaded."&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Warning: nginx_proxy container not found, skipping reload."&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Start Keycloak&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Starting Keycloak..."&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/docker-compose.yml"&lt;/span&gt; up &lt;span class="nt"&gt;-d&lt;/span&gt;

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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fills &lt;code&gt;DOMAIN&lt;/code&gt; from &lt;code&gt;.env&lt;/code&gt; into &lt;code&gt;nginx/vhost.conf&lt;/code&gt; and writes the result to &lt;code&gt;/srv/nginx-vhosts/${DOMAIN}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reloads &lt;code&gt;nginx_proxy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Starts Keycloak with &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Admin console access
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/auth/admin/&lt;/code&gt; and &lt;code&gt;/auth/realms/master/&lt;/code&gt; are closed to the outside world. Only &lt;code&gt;127.0.0.1&lt;/code&gt; can reach them.&lt;/p&gt;

&lt;p&gt;Open an SSH tunnel from your local machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 8443:127.0.0.1:443 user@server &lt;span class="nt"&gt;-N&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add this line to &lt;code&gt;/etc/hosts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1 auth.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open in the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://auth.example.com:8443/auth/admin/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log in with the credentials from &lt;code&gt;KEYCLOAK_ADMIN&lt;/code&gt; and &lt;code&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/code&gt;. Close the tunnel with &lt;code&gt;Ctrl+C&lt;/code&gt; when done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;After Keycloak starts, open the admin console through the SSH tunnel. You will see the Keycloak login screen.&lt;/p&gt;

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

&lt;p&gt;Log in with the credentials from &lt;code&gt;KEYCLOAK_ADMIN&lt;/code&gt; and &lt;code&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After logging in, you start in the master realm. Do not use it for your applications and users: master controls Keycloak itself, including the admin account, and a leaked token from your applications should never reach it.&lt;/p&gt;

&lt;p&gt;Create a separate realm for your organization instead. Click &lt;strong&gt;Create realm&lt;/strong&gt;, give it a name (e.g. your company name), and save.&lt;/p&gt;

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

&lt;p&gt;From this point, the realm is ready for clients, users, and roles. Everything you connect to Keycloak goes into this realm, not into master.&lt;/p&gt;

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

&lt;p&gt;We now have a working Keycloak instance with a Postgres database and a locked down admin console. It can create realms, manage users, and issue tokens, which is everything a service needs to delegate its login screen to Keycloak.&lt;/p&gt;

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