<?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: DeployLark</title>
    <description>The latest articles on DEV Community by DeployLark (@deploylark).</description>
    <link>https://dev.to/deploylark</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%2F4188014%2Feff5e0ad-288f-4605-8094-070bce7d34ad.png</url>
      <title>DEV Community: DeployLark</title>
      <link>https://dev.to/deploylark</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deploylark"/>
    <language>en</language>
    <item>
      <title>Elasticsearch in Docker: Fix vm.max_map_count Too Low</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sat, 07 Nov 2026 02:46:33 +0000</pubDate>
      <link>https://dev.to/deploylark/elasticsearch-in-docker-fix-vmmaxmapcount-too-low-25aa</link>
      <guid>https://dev.to/deploylark/elasticsearch-in-docker-fix-vmmaxmapcount-too-low-25aa</guid>
      <description>&lt;p&gt;You paste an Elasticsearch service into Compose, run &lt;code&gt;docker compose up&lt;/code&gt;, and the container exits with code 78. The log says &lt;code&gt;max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]&lt;/code&gt;. Or the node starts, your app still can't talk to it because &lt;code&gt;localhost:9200&lt;/code&gt; is the app's own loopback, or security returns &lt;code&gt;401&lt;/code&gt; until you set &lt;code&gt;ELASTIC_PASSWORD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide reproduces those failures with &lt;code&gt;docker.elastic.co/elasticsearch/elasticsearch:9.1.5&lt;/code&gt; and a &lt;code&gt;curlimages/curl:8.16.0&lt;/code&gt; client on the same Compose network, then fixes them. Every error string below comes from that lab run on November 7, 2026. The Stack Overflow question &lt;a href="https://stackoverflow.com/questions/51445846/elasticsearch-max-virtual-memory-areas-vm-max-map-count-65530-is-too-low-inc" rel="noopener noreferrer"&gt;Elasticsearch: Max virtual memory areas vm.max_map_count [65530] is too low&lt;/a&gt; had 402,986 views that day, &lt;a href="https://stackoverflow.com/questions/42889241/how-to-increase-vm-max-map-count" rel="noopener noreferrer"&gt;How to increase vm.max_map_count?&lt;/a&gt; had 201,093, and &lt;a href="https://stackoverflow.com/questions/41064572/docker-elk-vm-max-map-count" rel="noopener noreferrer"&gt;Docker - ELK - vm.max_map_count&lt;/a&gt; had 72,355.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raise &lt;code&gt;vm.max_map_count&lt;/code&gt; on the Docker host, not inside the container. Elastic's current docs recommend &lt;code&gt;1048576&lt;/code&gt;. The bootstrap check text still says at least &lt;code&gt;262144&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production-style settings (&lt;code&gt;network.host&lt;/code&gt; plus &lt;code&gt;cluster.initial_master_nodes&lt;/code&gt;) enforce the check and exit 78. &lt;code&gt;discovery.type=single-node&lt;/code&gt; may only WARN and still start.&lt;/li&gt;
&lt;li&gt;From another container, use the Compose service name (&lt;code&gt;http://elasticsearch:9200&lt;/code&gt;), not &lt;code&gt;localhost&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;With security enabled (the 8.x and 9.x default), set &lt;code&gt;ELASTIC_PASSWORD&lt;/code&gt; and send Basic auth.&lt;/li&gt;
&lt;li&gt;Persist the sysctl in &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; so a reboot does not bring the failure back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Fix vm.max_map_count bootstrap exit 78
&lt;/h2&gt;

&lt;p&gt;Start with the map. Match the message you see to the cause and the fix:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F01-elasticsearch-docker-error-cause-fix-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F01-elasticsearch-docker-error-cause-fix-diagram.webp" alt="Table diagram mapping Elasticsearch Docker errors to causes and fixes: vm.max_map_count bootstrap exit 78, localhost connection refused on 9200, and security_exception 401" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Confirm the host value and the exact log line
&lt;/h3&gt;

&lt;p&gt;The lab host started at the classic low default:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;With a production-style Compose file that binds a non-loopback address and sets &lt;code&gt;cluster.initial_master_nodes&lt;/code&gt;, Elasticsearch enforced bootstrap checks and died:&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;elasticsearch&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;docker.elastic.co/elasticsearch/elasticsearch:9.1.5&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;cluster.name=eslab&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node.name=eslab-node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cluster.initial_master_nodes=eslab-node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xpack.security.enabled=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ES_JAVA_OPTS=-Xms512m -Xmx512m&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;network.host=0.0.0.0&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:9200:9200"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container status was &lt;code&gt;Exited (78)&lt;/code&gt;. The log named the check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bound or publishing to a non-loopback address, enforcing bootstrap checks
ERROR node validation exception
[1] bootstrap checks failed. You must address the points described in the following [1] lines before starting Elasticsearch.
bootstrap check failure [1] of [1]: max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]
ERROR: Elasticsearch died while starting up, with exit code 78
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F02-elasticsearch-docker-vm-max-map-count-bootstrap-fail.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F02-elasticsearch-docker-vm-max-map-count-bootstrap-fail.webp" alt="Terminal capture: Elasticsearch container exits with code 78 and logs that vm.max_map_count 65530 is too low while enforcing bootstrap checks" width="800" height="203"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Elasticsearch uses memory-mapped files for indices by default. The kernel limit &lt;code&gt;vm.max_map_count&lt;/code&gt; caps how many mappings a process may hold. The &lt;a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/vm-max-map-count" rel="noopener noreferrer"&gt;Increase virtual memory&lt;/a&gt; docs say: "If the operating system's default &lt;code&gt;vm.max_map_count&lt;/code&gt; value is &lt;code&gt;1048576&lt;/code&gt; or higher, no configuration change is necessary. If the default value is lower than &lt;code&gt;1048576&lt;/code&gt;, configure the &lt;code&gt;vm.max_map_count&lt;/code&gt; parameter to &lt;code&gt;1048576&lt;/code&gt;." The &lt;a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod" rel="noopener noreferrer"&gt;Docker production guide&lt;/a&gt; repeats that the setting "must be set to &lt;code&gt;1048576&lt;/code&gt;." The check message still quotes the older floor of &lt;code&gt;262144&lt;/code&gt;. Raise the host to &lt;code&gt;1048576&lt;/code&gt; so you clear both the check and the current docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Raise it on the Docker host, then persist it
&lt;/h3&gt;

&lt;p&gt;You cannot fix this from inside the container. The value lives on the host kernel. On Linux:&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 &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; vm.max_map_count&lt;span class="o"&gt;=&lt;/span&gt;1048576
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'vm.max_map_count=1048576'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-vm-max-map-count.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify with &lt;code&gt;cat /proc/sys/vm/max_map_count&lt;/code&gt; or &lt;code&gt;sysctl vm.max_map_count&lt;/code&gt;. After the lab raised the host to &lt;code&gt;1048576&lt;/code&gt; and restarted the same Compose file, &lt;code&gt;curl http://127.0.0.1:9200&lt;/code&gt; returned the cluster root JSON with &lt;code&gt;"number" : "9.1.5"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F03-elasticsearch-docker-sysctl-vm-max-map-count-fix.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F03-elasticsearch-docker-sysctl-vm-max-map-count-fix.webp" alt="Terminal capture: sysctl raises vm.max_map_count to 1048576, then curl to port 9200 returns Elasticsearch 9.1.5 cluster JSON" width="800" height="203"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On Docker Desktop for Windows or Mac the knob sits in the Linux VM behind Docker, not in your Windows or macOS shell. Elastic's Docker production page documents &lt;code&gt;wsl -d docker-desktop&lt;/code&gt; and &lt;code&gt;.wslconfig&lt;/code&gt; paths for WSL2. Change it there, then restart the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  single-node can WARN and still start
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;discovery.type=single-node&lt;/code&gt; and the same low host value of &lt;code&gt;65530&lt;/code&gt;, the lab only logged a WARN and the node stayed up with HTTP 200:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARN max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F06-elasticsearch-docker-single-node-warn-only.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F06-elasticsearch-docker-single-node-warn-only.webp" alt="Terminal capture: discovery.type single-node only warns that vm.max_map_count is too low while curl still returns HTTP 200" width="800" height="140"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That soft path is why some Compose snippets "work" on a laptop and then explode when you switch to production-style networking. Fix the host either way. Do not treat &lt;code&gt;single-node&lt;/code&gt; as a substitute for &lt;code&gt;sysctl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix Connection refused to localhost:9200
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Don't connect to localhost from the app container
&lt;/h3&gt;

&lt;p&gt;Once Elasticsearch was healthy, a curl client on the same Compose network played the app. With &lt;code&gt;http://127.0.0.1:9200/&lt;/code&gt; it never reached Elasticsearch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl: (7) Failed to connect to 127.0.0.1 port 9200 after 0 ms: Could not connect to server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same client with &lt;code&gt;http://elasticsearch:9200/&lt;/code&gt; got the cluster root JSON. Each container has its own loopback interface, so &lt;code&gt;localhost&lt;/code&gt; inside the app is the app. Elasticsearch listens in the &lt;code&gt;elasticsearch&lt;/code&gt; service. The &lt;a href="https://deploylark.com/posts/redis-docker-connection-refused-noauth-misconf/" rel="noopener noreferrer"&gt;Redis guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/rabbitmq-docker-access-refused/" rel="noopener noreferrer"&gt;RabbitMQ guide&lt;/a&gt; show the same mistake for those brokers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F04-elasticsearch-docker-localhost-connection-refused.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F04-elasticsearch-docker-localhost-connection-refused.webp" alt="Terminal capture: curl from an app container fails on 127.0.0.1:9200 then succeeds on http://elasticsearch:9200" width="798" height="165"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From the Docker host itself you also need a published port. Without &lt;code&gt;ports&lt;/code&gt;, a host tool cannot reach 9200. Add &lt;code&gt;ports: ["127.0.0.1:9200:9200"]&lt;/code&gt; if a process on the host must talk HTTP. Other Compose services do not need that mapping.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wait until the HTTP port is ready
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;depends_on: [elasticsearch]&lt;/code&gt; only waits for the container process to start. Right after a restart in the lab, an immediate &lt;code&gt;curl http://elasticsearch:9200/&lt;/code&gt; still failed to connect. After the healthcheck flipped to healthy, the same request returned 200. Use a healthcheck and &lt;code&gt;condition: service_healthy&lt;/code&gt;, the pattern also used in the &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami guide&lt;/a&gt; with PostgreSQL:&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;elasticsearch&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;docker.elastic.co/elasticsearch/elasticsearch:9.1.5&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;discovery.type=single-node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xpack.security.enabled=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ES_JAVA_OPTS=-Xms512m -Xmx512m&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;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-fsS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:9200&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;/dev/null&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;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24&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;25s&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app:tag&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;elasticsearch&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix 401 missing authentication credentials
&lt;/h2&gt;

&lt;p&gt;Elasticsearch 8.x and 9.x enable security by default. The lab turned it on with &lt;code&gt;xpack.security.enabled=true&lt;/code&gt;, set &lt;code&gt;ELASTIC_PASSWORD=LabPassw0rd!&lt;/code&gt;, and disabled HTTP TLS for a local Compose network with &lt;code&gt;xpack.security.http.ssl.enabled=false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without credentials, curl got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"error":{"type":"security_exception","reason":"missing authentication credentials for REST request [/]"},"status":401}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a wrong password it got &lt;code&gt;unable to authenticate user [elastic]&lt;/code&gt;. With &lt;code&gt;-u elastic:LabPassw0rd!&lt;/code&gt; the root endpoint returned 200, a document indexed under &lt;code&gt;lab-notes&lt;/code&gt;, and a search hit after &lt;code&gt;_refresh&lt;/code&gt;. The same password pattern shows up for other databases in the &lt;a href="https://deploylark.com/posts/postgres-docker-password-authentication-failed/" rel="noopener noreferrer"&gt;PostgreSQL Docker auth guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/mongodb-docker-authentication-failed/" rel="noopener noreferrer"&gt;MongoDB Docker auth guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F05-elasticsearch-docker-elastic-password-401.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Felasticsearch-docker-vm-max-map-count%2F05-elasticsearch-docker-elastic-password-401.webp" alt="Terminal capture: Elasticsearch returns 401 without credentials or with a wrong password, then succeeds with elastic user and ELASTIC_PASSWORD" width="798" height="165"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Minimal secure Compose shape used in the lab:&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;elasticsearch&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;docker.elastic.co/elasticsearch/elasticsearch:9.1.5&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;discovery.type=single-node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xpack.security.enabled=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ELASTIC_PASSWORD=LabPassw0rd!&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xpack.security.http.ssl.enabled=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ES_JAVA_OPTS=-Xms512m -Xmx512m&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:9200:9200"&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;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-fsS&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;elastic:LabPassw0rd!&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:9200&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;/dev/null&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;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the password in an env file or a secret, not in a public git repo. For production TLS and enrollment tokens, follow Elastic's current Docker docs rather than disabling HTTP SSL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working Compose checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Set host &lt;code&gt;vm.max_map_count=1048576&lt;/code&gt; and persist it under &lt;code&gt;/etc/sysctl.d/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pin the image tag (&lt;code&gt;9.1.5&lt;/code&gt; in this lab, never &lt;code&gt;latest&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Give the node enough heap for your box (&lt;code&gt;ES_JAVA_OPTS=-Xms512m -Xmx512m&lt;/code&gt; was enough here).&lt;/li&gt;
&lt;li&gt;Bind a data volume to &lt;code&gt;/usr/share/elasticsearch/data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Point app containers at &lt;code&gt;http://elasticsearch:9200&lt;/code&gt; (or your service name) with credentials when security is on.&lt;/li&gt;
&lt;li&gt;Gate app start on &lt;code&gt;condition: service_healthy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Manage the stack from &lt;a href="https://deploylark.com/posts/portainer-ce-install-stacks/" rel="noopener noreferrer"&gt;Portainer&lt;/a&gt; if you prefer a UI over the CLI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://deploylark.com/posts/grafana-prometheus-docker-permission-denied/" rel="noopener noreferrer"&gt;Grafana and Prometheus permission guide&lt;/a&gt; is another case where the Docker host, not the Compose YAML alone, owns the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h4&gt;
  
  
  What does vm.max_map_count control?
&lt;/h4&gt;

&lt;p&gt;It is the Linux kernel limit on memory-mapped areas per process. Elasticsearch maps index files with &lt;code&gt;mmap&lt;/code&gt;, so a low value fails bootstrap or risks mapping errors. See &lt;a href="https://stackoverflow.com/questions/42889241/how-to-increase-vm-max-map-count" rel="noopener noreferrer"&gt;SO 42889241&lt;/a&gt; and Elastic's virtual memory docs.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does the container exit with code 78?
&lt;/h4&gt;

&lt;p&gt;Exit 78 is the lab result when bootstrap checks fail under enforced mode. The log line names &lt;code&gt;vm.max_map_count&lt;/code&gt;. Fix the host and start again.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does discovery.type=single-node remove the need for sysctl?
&lt;/h4&gt;

&lt;p&gt;No. In the lab it only logged a WARN and still started. Production-style networking enforced the check and exited 78. Raise the host either way.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I set vm.max_map_count inside the Elasticsearch container?
&lt;/h4&gt;

&lt;p&gt;No. Threads such as &lt;a href="https://stackoverflow.com/questions/41064572/docker-elk-vm-max-map-count" rel="noopener noreferrer"&gt;SO 41064572&lt;/a&gt; hit a read-only &lt;code&gt;/proc&lt;/code&gt; from inside the container. Change the Docker host (or the Docker Desktop VM).&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does my app get connection refused on localhost:9200?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;localhost&lt;/code&gt; inside the app container is that container. Use the Compose service name and wait for the healthcheck. Same pattern as Redis and RabbitMQ on this site.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I set the elastic user password in Compose?
&lt;/h4&gt;

&lt;p&gt;Set &lt;code&gt;ELASTIC_PASSWORD&lt;/code&gt; (and keep security enabled). Call the API with &lt;code&gt;-u elastic:$ELASTIC_PASSWORD&lt;/code&gt;. Missing or wrong credentials return &lt;code&gt;security_exception&lt;/code&gt; with status 401, as captured in the lab.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>elasticsearch</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Redis in Docker: Fix Connection Refused, NOAUTH and MISCONF</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:38:55 +0000</pubDate>
      <link>https://dev.to/deploylark/redis-in-docker-fix-connection-refused-noauth-and-misconf-40be</link>
      <guid>https://dev.to/deploylark/redis-in-docker-fix-connection-refused-noauth-and-misconf-40be</guid>
      <description>&lt;p&gt;You add the official &lt;code&gt;redis&lt;/code&gt; image to a Compose file, and your app logs &lt;code&gt;Could not connect to Redis at 127.0.0.1:6379: Connection refused&lt;/code&gt;. Or it gets &lt;code&gt;DENIED Redis is running in protected mode&lt;/code&gt;, or &lt;code&gt;NOAUTH Authentication required&lt;/code&gt;. Or Redis works until its first snapshot, and then every write fails with &lt;code&gt;MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide reproduces each of those errors with &lt;code&gt;redis:8.10.3&lt;/code&gt; in Docker Compose and fixes them. Every error message and log line below comes from that lab run on October 31, 2026, when &lt;code&gt;redis:latest&lt;/code&gt; pulled the same 8.10.3 image. These errors are common. The Stack Overflow question &lt;a href="https://stackoverflow.com/questions/19581059/misconf-redis-is-configured-to-save-rdb-snapshots" rel="noopener noreferrer"&gt;MISCONF Redis is configured to save RDB snapshots&lt;/a&gt; had 646,097 views that day, &lt;a href="https://stackoverflow.com/questions/41371402/connecting-to-redis-running-in-docker-container-from-host-machine" rel="noopener noreferrer"&gt;Connecting to Redis running in Docker Container from Host machine&lt;/a&gt; had 306,329, and the image repo issue &lt;a href="https://github.com/redis/docker-library-redis/issues/46" rel="noopener noreferrer"&gt;Set password and other options via ENV variable&lt;/a&gt; has 53 comments.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From another container, &lt;code&gt;localhost&lt;/code&gt; is that container. Connect to the Compose service name, for example &lt;code&gt;redis&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A mounted stock &lt;code&gt;redis.conf&lt;/code&gt; listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only and turns protected mode on. Change &lt;code&gt;bind&lt;/code&gt; and set a password.&lt;/li&gt;
&lt;li&gt;The official image ignores &lt;code&gt;REDIS_PASSWORD&lt;/code&gt; and &lt;code&gt;REDIS_ARGS&lt;/code&gt;. Pass &lt;code&gt;--requirepass&lt;/code&gt; on the command line or use a config or ACL file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redis-cli ping&lt;/code&gt; exits 0 on &lt;code&gt;NOAUTH&lt;/code&gt;, so a plain ping healthcheck stays healthy. Set &lt;code&gt;REDISCLI_AUTH&lt;/code&gt; and check for &lt;code&gt;PONG&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MISCONF&lt;/code&gt; and &lt;code&gt;appendonlydir: Permission denied&lt;/code&gt; mean the Redis user (uid 999) can't write &lt;code&gt;/data&lt;/code&gt;. Fix the ownership instead of turning the check off.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix &lt;code&gt;Connection refused&lt;/code&gt; from another container
&lt;/h2&gt;

&lt;p&gt;Start with the map. Find the message you see in the left column:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F01-redis-docker-error-cause-fix-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F01-redis-docker-error-cause-fix-diagram.webp" alt="Table diagram mapping five Redis Docker errors to their cause and fix: Connection refused on localhost, Connection refused with a mounted redis.conf, DENIED protected mode, NOAUTH or no password asked, and MISCONF or appendonlydir Permission denied" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lab started with the smallest possible service, no config file and no published port:&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;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:8.10.3&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis-data:/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Don't connect to localhost from the app container
&lt;/h3&gt;

&lt;p&gt;A one-off &lt;code&gt;redis:8.10.3&lt;/code&gt; container on the same Compose network played the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ redis-cli -h localhost PING
Could not connect to Redis at localhost:6379: Connection refused
$ redis-cli -h 127.0.0.1 -p 6379 PING
Could not connect to Redis at 127.0.0.1:6379: Connection refused
$ redis-cli -h redis PING
PONG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each container has its own loopback interface, so &lt;code&gt;localhost&lt;/code&gt; inside the app container is the app container. Redis listens in its own container, which Compose names after the service. Set your app's Redis host to &lt;code&gt;redis&lt;/code&gt; (or whatever you called the service), for example &lt;code&gt;redis://redis:6379/0&lt;/code&gt;. The container name &lt;code&gt;redislab-redis-1&lt;/code&gt; also answered. The &lt;a href="https://deploylark.com/posts/mysql-docker-access-denied-root/" rel="noopener noreferrer"&gt;MySQL guide&lt;/a&gt; shows the same mistake with ERROR 2002 and 2003.&lt;/p&gt;

&lt;p&gt;From the Docker host itself, the port was closed too. &lt;code&gt;docker compose ps&lt;/code&gt; listed &lt;code&gt;6379/tcp&lt;/code&gt; with no host mapping, and a TCP connect to &lt;code&gt;127.0.0.1:6379&lt;/code&gt; on the host was refused. Add &lt;code&gt;ports: ["127.0.0.1:6379:6379"]&lt;/code&gt; if a tool on the host needs Redis. Keep the &lt;code&gt;127.0.0.1&lt;/code&gt; part. Other containers on the network don't need &lt;code&gt;ports&lt;/code&gt; at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check &lt;code&gt;bind&lt;/code&gt; in a mounted redis.conf
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://hub.docker.com/_/redis" rel="noopener noreferrer"&gt;image docs&lt;/a&gt; show how to start Redis with your own &lt;code&gt;redis.conf&lt;/code&gt;. If you start from the upstream file, note that the 8.10.3 version from the &lt;a href="https://github.com/redis/redis/blob/8.10.3/redis.conf" rel="noopener noreferrer"&gt;redis/redis repository&lt;/a&gt; contains &lt;code&gt;bind 127.0.0.1 -::1&lt;/code&gt; and &lt;code&gt;protected-mode yes&lt;/code&gt;. Mounted as-is, Redis answers inside its own container and refuses everyone else:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F02-redis-docker-connection-refused-localhost-bind.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F02-redis-docker-connection-refused-localhost-bind.webp" alt="Terminal output from an app container: redis-cli to localhost and 127.0.0.1 is refused, the service name redis returns PONG, and after mounting the stock redis.conf the service name is refused too while redis-cli inside the Redis container still returns PONG" width="800" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CONFIG GET bind&lt;/code&gt; inside the container returned &lt;code&gt;127.0.0.1 -::1&lt;/code&gt;. A published port didn't help either: from the host, &lt;code&gt;redis-cli&lt;/code&gt; got &lt;code&gt;Error: Connection reset by peer&lt;/code&gt;. This is the accepted answer on the 306,329-view question above: the server only listens on loopback. Change the line to &lt;code&gt;bind * -::*&lt;/code&gt;, which the same file describes as "like the default, all available interfaces". That leads straight to the next error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix &lt;code&gt;DENIED Redis is running in protected mode&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the plain image has no protected mode
&lt;/h3&gt;

&lt;p&gt;Without a config file, the image turns protected mode off. In the lab, &lt;code&gt;CONFIG GET protected-mode&lt;/code&gt; returned &lt;code&gt;no&lt;/code&gt;, &lt;code&gt;bind&lt;/code&gt; was &lt;code&gt;* -::*&lt;/code&gt; and &lt;code&gt;requirepass&lt;/code&gt; was empty. Redis logged this on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARNING: Redis does not require authentication and is not protected by network restrictions. Redis will accept connections from any IP address on any network interface.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://hub.docker.com/_/redis" rel="noopener noreferrer"&gt;Docker Official Image docs&lt;/a&gt; explain the choice: "For the ease of accessing Redis from other containers via Docker networking, the "Protected mode" is turned off by default. This means that if you expose the port outside of your host (e.g., via &lt;code&gt;-p&lt;/code&gt; on &lt;code&gt;docker run&lt;/code&gt;), it will be open without a password to anyone."&lt;/p&gt;

&lt;h3&gt;
  
  
  Set a password instead of turning protected mode off
&lt;/h3&gt;

&lt;p&gt;With a mounted &lt;code&gt;redis.conf&lt;/code&gt; you get the upstream default, &lt;code&gt;protected-mode yes&lt;/code&gt;. After the &lt;code&gt;bind * -::*&lt;/code&gt; change and no password, every client from another container got the long &lt;code&gt;DENIED&lt;/code&gt; reply:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F03-redis-denied-protected-mode-docker.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F03-redis-denied-protected-mode-docker.webp" alt="Terminal output showing the full DENIED Redis is running in protected mode message from another container, then NOAUTH Authentication required after adding requirepass, and PONG once REDISCLI_AUTH supplies the password" width="800" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The message offers four ways out. Three of them switch protected mode off. Option 4, a password for the default user, is the one to pick. Adding &lt;code&gt;requirepass Lab-Pass-4821&lt;/code&gt; to &lt;code&gt;redis.conf&lt;/code&gt; and restarting turned &lt;code&gt;DENIED&lt;/code&gt; into &lt;code&gt;NOAUTH Authentication required.&lt;/code&gt;, and the same client with the password got &lt;code&gt;PONG&lt;/code&gt;. The &lt;a href="https://redis.io/docs/latest/operate/oss_and_stack/management/security/" rel="noopener noreferrer"&gt;Redis security docs&lt;/a&gt; state the design plainly: "Redis is designed to be accessed by trusted clients inside trusted environments." A password plus a private Compose network keeps it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix NOAUTH and a password that never applied
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pass the password with &lt;code&gt;--requirepass&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first lab file had &lt;code&gt;REDIS_PASSWORD: "ChangeMe123"&lt;/code&gt; in &lt;code&gt;environment&lt;/code&gt;. Redis started without a password anyway: &lt;code&gt;CONFIG GET requirepass&lt;/code&gt; came back empty, and a client from another container ran &lt;code&gt;PING&lt;/code&gt; without any auth. The entrypoint script in &lt;code&gt;redis:8.10.3&lt;/code&gt; never reads &lt;code&gt;REDIS_PASSWORD&lt;/code&gt;, and it contains no &lt;code&gt;REDIS_ARGS&lt;/code&gt; either. A test with &lt;code&gt;REDIS_ARGS: "--requirepass Lab-Pass-4821"&lt;/code&gt; also left Redis open, and &lt;code&gt;SET&lt;/code&gt; worked without a password. A &lt;a href="https://stackoverflow.com/questions/70807705/how-to-use-redis-username-with-password-in-docker-compose" rel="noopener noreferrer"&gt;Stack Overflow answer&lt;/a&gt; notes that &lt;code&gt;REDIS_ARGS&lt;/code&gt; works with the &lt;code&gt;redis/redis-stack&lt;/code&gt; images. The official &lt;code&gt;redis&lt;/code&gt; image doesn't support it.&lt;/p&gt;

&lt;p&gt;Instead, put the password in &lt;code&gt;.env&lt;/code&gt; and let Compose interpolate it into the command:&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;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:8.10.3&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis-server"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--requirepass"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${REDIS_PASSWORD:?set&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;REDIS_PASSWORD&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.env}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;REDIS_PASSWORD=Lab-Pass-4821&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, this produced the errors your app shows when its password is missing or wrong:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F04-redis-noauth-healthcheck-docker-compose.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F04-redis-noauth-healthcheck-docker-compose.webp" alt="Terminal output: redis-cli PING without a password prints NOAUTH Authentication required with exit code 0, a wrong password prints WRONGPASS invalid username-password pair, REDISCLI_AUTH returns PONG, and docker compose ps shows a naive ping healthcheck as healthy while a wrong-password healthcheck is unhealthy" width="800" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NOAUTH Authentication required.&lt;/code&gt; means the client sent no password. &lt;code&gt;WRONGPASS invalid username-password pair or user is disabled.&lt;/code&gt; means it sent the wrong one. With &lt;code&gt;-a&lt;/code&gt;, &lt;code&gt;redis-cli&lt;/code&gt; also prints a warning that a password on the command line may not be safe. Set &lt;code&gt;REDISCLI_AUTH&lt;/code&gt; instead, or add &lt;code&gt;--no-auth-warning&lt;/code&gt;. Without &lt;code&gt;.env&lt;/code&gt;, the &lt;code&gt;:?&lt;/code&gt; form stops Compose with &lt;code&gt;required variable REDIS_PASSWORD is missing a value&lt;/code&gt;, so Redis never starts open by accident.&lt;/p&gt;

&lt;p&gt;Two details from the lab. &lt;code&gt;docker inspect&lt;/code&gt; shows the password in the container's &lt;code&gt;Args&lt;/code&gt;. And the shell form from a &lt;a href="https://stackoverflow.com/questions/68461172/docker-compose-redis-password-via-environment-variable" rel="noopener noreferrer"&gt;41-vote answer&lt;/a&gt; also worked, with &lt;code&gt;redis-server --requirepass "$${REDIS_PASSWORD:?REDIS_PASSWORD variable is not set}"&lt;/code&gt; after &lt;code&gt;/bin/sh -c&lt;/code&gt; and the password in &lt;code&gt;environment&lt;/code&gt;. The &lt;code&gt;$$&lt;/code&gt; stops Compose from replacing the variable itself. That keeps the value out of &lt;code&gt;Args&lt;/code&gt;, but it still sits in the container's environment.&lt;/p&gt;

&lt;p&gt;The healthcheck has the same blind spot, as the bottom of the screenshot above shows. A healthcheck of &lt;code&gt;["CMD", "redis-cli", "ping"]&lt;/code&gt; against a password-protected Redis stayed &lt;code&gt;healthy&lt;/code&gt;. Its last probe printed &lt;code&gt;NOAUTH Authentication required.&lt;/code&gt; and exited 0. So &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; lets your app start against a Redis it can't log in to.&lt;/p&gt;

&lt;p&gt;This check reports the truth:&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;REDISCLI_AUTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${REDIS_PASSWORD}&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;redis-cli&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping&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;PONG"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the right password the service was &lt;code&gt;healthy&lt;/code&gt;. With a wrong &lt;code&gt;REDISCLI_AUTH&lt;/code&gt; it turned &lt;code&gt;unhealthy&lt;/code&gt;, and the probe log showed the &lt;code&gt;WRONGPASS&lt;/code&gt; error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use an ACL user when your app wants a username
&lt;/h3&gt;

&lt;p&gt;Some apps ask for a Redis username and password. Define the user in an ACL file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user default off
user app on &amp;gt;App-Pass-7730 ~app:* +@all -@dangerous
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount it read-only and start Redis with &lt;code&gt;command: ["redis-server", "--aclfile", "/usr/local/etc/redis/users.acl"]&lt;/code&gt;. In the lab, &lt;code&gt;redis-cli --user app --pass App-Pass-7730 PING&lt;/code&gt; returned &lt;code&gt;PONG&lt;/code&gt;, and a URL like &lt;code&gt;redis://app:App-Pass-7730@redis:6379&lt;/code&gt; worked too. A password without the username failed with &lt;code&gt;WRONGPASS&lt;/code&gt;, because the default user is off. The &lt;code&gt;~app:*&lt;/code&gt; key pattern did its job: &lt;code&gt;SET other 1&lt;/code&gt; returned &lt;code&gt;NOPERM No permissions to access a key&lt;/code&gt;, and &lt;code&gt;FLUSHALL&lt;/code&gt; was refused. Putting the same rules on the command line went wrong in the lab. Written as one list item after &lt;code&gt;--user&lt;/code&gt;, Redis exited with &lt;code&gt;Spaces not allowed in ACL usernames&lt;/code&gt;. The file avoids that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix MISCONF and append-only Permission denied
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why saves to /data fail
&lt;/h3&gt;

&lt;p&gt;The Redis server runs as uid 999 (&lt;code&gt;redis&lt;/code&gt;) in this image. When the container starts as root, the entrypoint fixes the ownership of &lt;code&gt;/data&lt;/code&gt; itself. The &lt;a href="https://hub.docker.com/_/redis" rel="noopener noreferrer"&gt;image docs&lt;/a&gt; add a limit: "This adjustment is only performed in basic, default scenarios to avoid interfering with custom or user-specific configurations."&lt;/p&gt;

&lt;p&gt;The lab hit that limit with a bind mount, &lt;code&gt;./data:/data&lt;/code&gt;, owned by root and holding a &lt;code&gt;.gitkeep&lt;/code&gt; file. The entrypoint printed &lt;code&gt;Notice: Unknown file './.gitkeep' found in data dir. Permissions will not be modified.&lt;/code&gt; and left the directory alone. Redis started, and reads and writes worked until the first snapshot:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F05-redis-misconf-permission-denied-docker-volume.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fredis-docker-connection-refused-noauth-misconf%2F05-redis-misconf-permission-denied-docker-volume.webp" alt="Terminal output: the entrypoint notice about an unknown file in the data dir, BGSAVE failing with Permission denied on the temp RDB file, SET returning the MISCONF error, the appendonlydir Permission denied log line, then chown to 999 fixing writes and rdb_last_bgsave_status ok" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The default save rule was &lt;code&gt;3600 1 300 100 60 10000&lt;/code&gt;. After a failed &lt;code&gt;BGSAVE&lt;/code&gt;, the log said &lt;code&gt;Failed opening the temp RDB file temp-51.rdb (in server root dir /data) for saving: Permission denied&lt;/code&gt;, &lt;code&gt;INFO persistence&lt;/code&gt; showed &lt;code&gt;rdb_last_bgsave_status:err&lt;/code&gt;, and the next &lt;code&gt;SET&lt;/code&gt; returned &lt;code&gt;MISCONF&lt;/code&gt;. Reads still worked, as &lt;code&gt;GET a&lt;/code&gt; returned &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;--appendonly yes&lt;/code&gt; the same mount fails at once. The container exited with code 1 after &lt;code&gt;Can't open or create append-only dir appendonlydir: Permission denied&lt;/code&gt;. A &lt;code&gt;user: "1000:1000"&lt;/code&gt; line on a fresh named volume, which the image creates owned by 999, failed with the same message.&lt;/p&gt;

&lt;p&gt;Every start in the lab logged &lt;code&gt;WARNING Memory overcommit must be enabled!&lt;/code&gt;, because the host had &lt;code&gt;vm.overcommit_memory&lt;/code&gt; set to 0. The warning says it can make a background save fail under low memory, and it names the fix: "To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect." This is a host setting. &lt;code&gt;docker run --sysctl vm.overcommit_memory=1&lt;/code&gt; failed with &lt;code&gt;sysctl 'vm.overcommit_memory=1' is not allowed&lt;/code&gt;, and the container read the host value of 0. Set it on the Docker host. A permission error, not this warning, caused every &lt;code&gt;MISCONF&lt;/code&gt; in this lab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix ownership, don't switch off the check
&lt;/h3&gt;

&lt;p&gt;The 533-vote answer on the MISCONF question runs &lt;code&gt;config set stop-writes-on-bgsave-error no&lt;/code&gt; and adds that you should check why the save failed. The lab shows why that matters. After that command, &lt;code&gt;SET&lt;/code&gt; worked again, but the next &lt;code&gt;BGSAVE&lt;/code&gt; still ended with &lt;code&gt;rdb_last_bgsave_status:err&lt;/code&gt;. Nothing reached the disk.&lt;/p&gt;

&lt;p&gt;Fix the directory instead. Any of these worked in the lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Change the owner to the Redis user with &lt;code&gt;sudo chown -R 999:999 ./data&lt;/code&gt; and restart. Writes worked, &lt;code&gt;BGSAVE&lt;/code&gt; returned &lt;code&gt;rdb_last_bgsave_status:ok&lt;/code&gt; and &lt;code&gt;dump.rdb&lt;/code&gt; appeared with owner 999.&lt;/li&gt;
&lt;li&gt;Run the command from the image docs, &lt;code&gt;docker run --rm -v /your/host/path:/data redis:8.10.3 chown -R redis:redis /data&lt;/code&gt;. It set the same root-owned folder to 999:999.&lt;/li&gt;
&lt;li&gt;Remove the unknown files, so the entrypoint fixes the folder on its own. With &lt;code&gt;.gitkeep&lt;/code&gt; gone it changed the owner to 999 and created &lt;code&gt;appendonlydir&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you run Redis with &lt;code&gt;user: "1000:1000"&lt;/code&gt;, make the volume belong to that user, for example &lt;code&gt;docker run --rm -v &amp;lt;volume&amp;gt;:/data alpine:3.22 chown 1000:1000 /data&lt;/code&gt;. After that the AOF service started and &lt;code&gt;aof_last_write_status:ok&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://deploylark.com/posts/grafana-prometheus-docker-permission-denied/" rel="noopener noreferrer"&gt;Grafana and Prometheus guide&lt;/a&gt; walks through the same uid mismatch with two other images.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Redis Compose file that avoids these errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  compose.yaml and .env
&lt;/h3&gt;

&lt;p&gt;This file ran verbatim in the lab:&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;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:8.10.3&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis-server&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--requirepass&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--appendonly&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yes"&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;REDISCLI_AUTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${REDIS_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:6379:6379"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis-data:/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="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;redis-cli&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping&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;PONG"&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;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;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;5s&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:8.10.3&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;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;REDISCLI_AUTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${REDIS_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis-cli"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-h"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INCR"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app:visits"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REDIS_PASSWORD=f3Kq9-Lab-Only-2Vx7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;app&lt;/code&gt; with your real service and give it the host &lt;code&gt;redis&lt;/code&gt; and the same password. The named volume belongs to the Redis user from the start, AOF is on, and the healthcheck only passes on a real &lt;code&gt;PONG&lt;/code&gt;. Drop the &lt;code&gt;ports&lt;/code&gt; block if nothing on the host needs Redis. If your password contains &lt;code&gt;$&lt;/code&gt;, read the dollar sign section of the &lt;a href="https://deploylark.com/posts/mysql-docker-access-denied-root/" rel="noopener noreferrer"&gt;MySQL guide&lt;/a&gt; first, because Compose interpolates &lt;code&gt;.env&lt;/code&gt; the same way. The &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami guide&lt;/a&gt; uses the same &lt;code&gt;service_healthy&lt;/code&gt; pattern with PostgreSQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker compose up -d
 Container redisfinal-redis-1  Waiting
 Container redisfinal-redis-1  Healthy
 Container redisfinal-app-1  Started
$ docker compose logs --no-log-prefix app
1
$ docker compose run --rm app
2
$ docker compose exec redis redis-cli INFO persistence | grep -E 'aof_enabled|aof_last_write_status'
aof_enabled:1
aof_last_write_status:ok
$ docker compose restart redis
$ docker compose exec redis redis-cli GET app:visits
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the host, &lt;code&gt;redis-cli -h 127.0.0.1 PING&lt;/code&gt; without a password got &lt;code&gt;NOAUTH Authentication required.&lt;/code&gt;, and with &lt;code&gt;REDISCLI_AUTH&lt;/code&gt; set it read the counter. The value survived the restart. Paperless-ngx uses a Redis-compatible broker in the same way, as the &lt;a href="https://deploylark.com/posts/paperless-ngx-docker-compose-ocr/" rel="noopener noreferrer"&gt;Paperless-ngx guide&lt;/a&gt; shows with Valkey.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Connection errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does Redis refuse connections between Docker Compose containers?
&lt;/h4&gt;

&lt;p&gt;Usually the app connects to &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;127.0.0.1&lt;/code&gt;, which is the app's own container. Use the service name, such as &lt;code&gt;redis&lt;/code&gt;. If you mounted your own &lt;code&gt;redis.conf&lt;/code&gt;, check its &lt;code&gt;bind&lt;/code&gt; line. The stock file has &lt;code&gt;bind 127.0.0.1 -::1&lt;/code&gt;, so other containers get &lt;code&gt;Connection refused&lt;/code&gt; until you change it.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I connect to Redis in Docker from the host machine?
&lt;/h4&gt;

&lt;p&gt;Publish the port, for example &lt;code&gt;ports: ["127.0.0.1:6379:6379"]&lt;/code&gt;, and connect to &lt;code&gt;127.0.0.1:6379&lt;/code&gt;. Without &lt;code&gt;ports&lt;/code&gt;, the host connection is refused. If Redis uses the stock &lt;code&gt;redis.conf&lt;/code&gt; with &lt;code&gt;bind 127.0.0.1 -::1&lt;/code&gt;, the published port still fails. In the lab, &lt;code&gt;redis-cli&lt;/code&gt; on the host got &lt;code&gt;Error: Connection reset by peer&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I fix &lt;code&gt;DENIED Redis is running in protected mode&lt;/code&gt; in Docker?
&lt;/h4&gt;

&lt;p&gt;Set a password with &lt;code&gt;requirepass&lt;/code&gt; or define an ACL user. Protected mode only blocks remote clients when the default user has no password. The plain image starts with protected mode off, so this error usually comes from a mounted &lt;code&gt;redis.conf&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Password and persistence errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I set a Redis password with an environment variable in docker-compose?
&lt;/h4&gt;

&lt;p&gt;The official image doesn't read &lt;code&gt;REDIS_PASSWORD&lt;/code&gt;. Put the value in &lt;code&gt;.env&lt;/code&gt; and pass it in &lt;code&gt;command&lt;/code&gt;, as in &lt;code&gt;["redis-server", "--requirepass", "${REDIS_PASSWORD}"]&lt;/code&gt;. Check with &lt;code&gt;CONFIG GET requirepass&lt;/code&gt; or a &lt;code&gt;PING&lt;/code&gt; without a password, which should return &lt;code&gt;NOAUTH&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I use a Redis username with a password in docker-compose?
&lt;/h4&gt;

&lt;p&gt;Create an ACL file with &lt;code&gt;user default off&lt;/code&gt; and a line such as &lt;code&gt;user app on &amp;gt;password ~app:* +@all&lt;/code&gt;, mount it, and start Redis with &lt;code&gt;--aclfile&lt;/code&gt;. Clients then log in with &lt;code&gt;--user&lt;/code&gt; and &lt;code&gt;--pass&lt;/code&gt;, or a URL like &lt;code&gt;redis://app:password@redis:6379&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I fix &lt;code&gt;MISCONF Redis is configured to save RDB snapshots&lt;/code&gt; in Docker?
&lt;/h4&gt;

&lt;p&gt;Read the Redis log for the reason. In Docker it is often &lt;code&gt;Permission denied&lt;/code&gt; on &lt;code&gt;/data&lt;/code&gt;. Change the data directory's owner to uid 999, or to the user you set with &lt;code&gt;user:&lt;/code&gt;, then restart. Setting &lt;code&gt;stop-writes-on-bgsave-error no&lt;/code&gt; hides the error but doesn't save anything.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>MongoDB in Docker: Fix Authentication Failed and Init Scripts</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:35:54 +0000</pubDate>
      <link>https://dev.to/deploylark/mongodb-in-docker-fix-authentication-failed-and-init-scripts-47nm</link>
      <guid>https://dev.to/deploylark/mongodb-in-docker-fix-authentication-failed-and-init-scripts-47nm</guid>
      <description>&lt;p&gt;You set &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; and &lt;code&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/code&gt; in Compose, start the official &lt;code&gt;mongo&lt;/code&gt; image, and your app logs &lt;code&gt;MongoServerError: Authentication failed.&lt;/code&gt; The password is right. You can see it in the container's environment. Or you changed the password in &lt;code&gt;.env&lt;/code&gt;, recreated the container, and now nothing can log in. Or the user your &lt;code&gt;docker-entrypoint-initdb.d&lt;/code&gt; script should create never shows up.&lt;/p&gt;

&lt;p&gt;This guide reproduces each of those failures with &lt;code&gt;mongo:9.0.4&lt;/code&gt; in Docker Compose and fixes them. Every error and log line below comes from that lab run on November 1, 2026, when &lt;code&gt;mongo:latest&lt;/code&gt; pointed to the same 9.0.4 image digest. The questions behind these errors get a lot of traffic. On Stack Overflow, &lt;a href="https://stackoverflow.com/questions/42912755/how-can-i-create-a-database-for-a-mongodb-container-on-startup" rel="noopener noreferrer"&gt;How can I create a database for a MongoDB container on startup?&lt;/a&gt; had 325,066 views that day and &lt;a href="https://stackoverflow.com/questions/34559557/how-to-enable-authentication-on-mongodb-through-docker" rel="noopener noreferrer"&gt;How to enable authentication on MongoDB through Docker?&lt;/a&gt; had 316,972. The image repo issue &lt;a href="https://github.com/docker-library/mongo/issues/329" rel="noopener noreferrer"&gt;Initialization of a user defined database, username, and password using environment variables&lt;/a&gt; has 29 comments.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From another container, &lt;code&gt;localhost&lt;/code&gt; is that container. Connect to the Compose service name, for example &lt;code&gt;mongodb://mongo:27017&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The root user from &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; lives in the &lt;code&gt;admin&lt;/code&gt; database. If your connection string names another database, add &lt;code&gt;?authSource=admin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;MONGO_INITDB_*&lt;/code&gt; variables and the &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; scripts only run when &lt;code&gt;/data/db&lt;/code&gt; is empty. Changing them later does nothing. Change the password with &lt;code&gt;changeUserPassword()&lt;/code&gt; or run the script by hand.&lt;/li&gt;
&lt;li&gt;Percent-encode &lt;code&gt;@ : / ? # [ ] $&lt;/code&gt; in the password part of the connection string.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker logs&lt;/code&gt; on the MongoDB container tells you why a login failed. Look for &lt;code&gt;Failed to authenticate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix Authentication failed with MONGO_INITDB_ROOT_USERNAME
&lt;/h2&gt;

&lt;p&gt;Start with the map. Find the message you see in the left column:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F01-mongodb-docker-error-cause-fix-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F01-mongodb-docker-error-cause-fix-diagram.webp" alt="Table diagram mapping five MongoDB Docker errors to their cause and fix: ECONNREFUSED on localhost, Authentication failed because of the wrong authSource, Authentication failed after a password change, initdb.d scripts that did not run, and unescaped characters in the password" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lab started with the smallest Compose file that turns on authentication:&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;mongo&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;mongo:9.0.4&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;MONGO_INITDB_ROOT_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
      &lt;span class="na"&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MONGO_ROOT_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;mongo-data:/data/db&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;mongo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MONGO_ROOT_PASSWORD=Lab-Pass-4821
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first start, the entrypoint ran a temporary &lt;code&gt;mongod&lt;/code&gt; bound to &lt;code&gt;127.0.0.1&lt;/code&gt;, created the root user, shut it down and then started the real server as &lt;code&gt;mongod --auth --bind_ip_all&lt;/code&gt;. A one-off &lt;code&gt;mongo:9.0.4&lt;/code&gt; container on the same Compose network played the app and ran &lt;code&gt;mongosh&lt;/code&gt; 2.14.0. &lt;code&gt;mongosh&lt;/code&gt; is built on the Node.js driver (the server log named driver 7.5.0), so Node.js apps get the same error names.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect to the service name, not localhost
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client$ mongosh 'mongodb://localhost:27017' --eval 'db.runCommand({ping:1})'
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017
client$ mongosh 'mongodb://mongo:27017' --eval 'db.runCommand({ping:1})'
{ ok: 1 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the app container, &lt;code&gt;localhost&lt;/code&gt; points back at the app itself. Nothing listens on port 27017 there, so the connection is refused. Use the service name from your Compose file. The same rule applies to MySQL and Redis, as the &lt;a href="https://deploylark.com/posts/mysql-docker-access-denied-root/" rel="noopener noreferrer"&gt;MySQL guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/redis-docker-connection-refused-noauth-misconf/" rel="noopener noreferrer"&gt;Redis guide&lt;/a&gt; show.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;ping&lt;/code&gt; worked without a password. MongoDB lets unauthenticated clients run &lt;code&gt;ping&lt;/code&gt;, but a real query failed with &lt;code&gt;MongoServerError: Command find requires authentication&lt;/code&gt;. That message means you connected without credentials. &lt;code&gt;Authentication failed.&lt;/code&gt; means you sent credentials and the server rejected them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add authSource=admin for the root user
&lt;/h3&gt;

&lt;p&gt;When the password is right and the login still fails, check the database in the connection string first. Here it named the app database, and the right password failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/appdb' --eval 'db.items.insertOne({a:1})'
MongoServerError: Authentication failed.
client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/appdb?authSource=admin' --eval 'db.items.insertOne({a:1}).acknowledged'
true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A MongoDB user belongs to the database where it was created. The &lt;a href="https://hub.docker.com/_/mongo" rel="noopener noreferrer"&gt;Docker Hub page for the image&lt;/a&gt; says &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; and &lt;code&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/code&gt; are for "creating a simple user with the role &lt;code&gt;root&lt;/code&gt; in the &lt;code&gt;admin&lt;/code&gt; authentication database". The &lt;a href="https://www.mongodb.com/docs/manual/reference/connection-string-options/" rel="noopener noreferrer"&gt;MongoDB connection string docs&lt;/a&gt; describe &lt;code&gt;authSource&lt;/code&gt; as the "Database name associated with the user's credentials. Defaults to defaultauthdb if specified, otherwise defaults to admin." So &lt;code&gt;/appdb&lt;/code&gt; in the path made the driver look for &lt;code&gt;root&lt;/code&gt; in &lt;code&gt;appdb&lt;/code&gt;. Without a database in the path, as in &lt;code&gt;mongodb://root:Lab-Pass-4821@mongo:27017/&lt;/code&gt;, the login worked because the default was &lt;code&gt;admin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The server log shows the difference between a wrong database and a wrong password. The client gets the same message for both:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F02-mongodb-docker-econnrefused-authsource.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F02-mongodb-docker-econnrefused-authsource.webp" alt="Terminal capture: mongosh to localhost gets ECONNREFUSED, a query without credentials gets Command find requires authentication, root with database appdb gets Authentication failed, adding authSource=admin works, and the server log shows UserNotFound for db appdb and storedKey mismatch for a wrong password" width="800" height="223"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker logs mongolab-mongo-1 2&amp;gt;&amp;amp;1 | grep '"Failed to authenticate"'
Failed to authenticate user=root db=appdb error=UserNotFound: Could not find user "root" for db "appdb"
Failed to authenticate user=root db=admin error=AuthenticationFailed: SCRAM authentication failed, storedKey mismatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw lines are JSON. The lab filtered them with &lt;code&gt;jq&lt;/code&gt; to show &lt;code&gt;attr.user&lt;/code&gt;, &lt;code&gt;attr.db&lt;/code&gt; and &lt;code&gt;attr.error&lt;/code&gt;. &lt;code&gt;UserNotFound&lt;/code&gt; means fix the &lt;code&gt;authSource&lt;/code&gt; or the user name. &lt;code&gt;storedKey mismatch&lt;/code&gt; means the user exists and the password is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix a password that changed in .env but not in MongoDB
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why MONGO_INITDB_ROOT_PASSWORD is ignored on restart
&lt;/h3&gt;

&lt;p&gt;The lab changed &lt;code&gt;MONGO_ROOT_PASSWORD&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; from &lt;code&gt;Lab-Pass-4821&lt;/code&gt; to &lt;code&gt;New-Pass-7310&lt;/code&gt; and ran &lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;. The new container had the new value in its environment, but the new password failed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F03-mongo-initdb-root-password-ignored-existing-volume.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F03-mongo-initdb-root-password-ignored-existing-volume.webp" alt="Terminal capture: after changing the password in .env the container environment shows New-Pass-7310 but the init process did not run and the new password fails, changeUserPassword with the old password fixes it, and a volume first started without auth runs mongod with auth on but no root user" width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The init step never ran. The entrypoint checks &lt;code&gt;/data/db&lt;/code&gt; for files such as &lt;code&gt;WiredTiger&lt;/code&gt;, &lt;code&gt;journal&lt;/code&gt; and &lt;code&gt;storage.bson&lt;/code&gt;, and skips initialization when any of them exist. The Docker Hub page says it directly: "Do note that none of the variables below will have any effect if you start the container with a data directory that already contains a database: any pre-existing database will always be left untouched on container startup."&lt;/p&gt;

&lt;p&gt;So the old password stays valid until you change it inside MongoDB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change the password with mongosh
&lt;/h3&gt;

&lt;p&gt;Log in with the old password and change it in place. Then the &lt;code&gt;.env&lt;/code&gt; value and the database agree again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client$ mongosh 'mongodb://root:Lab-Pass-4821@mongo:27017/?authSource=admin' \
          --eval 'db.getSiblingDB("admin").changeUserPassword("root", "New-Pass-7310")'
{ ok: 1 }
client$ mongosh 'mongodb://root:New-Pass-7310@mongo:27017/?authSource=admin' \
          --eval 'db.runCommand({connectionStatus:1}).authInfo.authenticatedUsers'
[ { user: 'root', db: 'admin' } ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you lost the old password, or the volume only holds test data, &lt;code&gt;docker compose down -v&lt;/code&gt; deletes the volume. In the lab, the next start ran the init step again and the new &lt;code&gt;.env&lt;/code&gt; password worked. That also deletes every database in the volume.&lt;/p&gt;

&lt;p&gt;A related trap: the lab started a volume with no auth variables, wrote data, then added &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; and &lt;code&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/code&gt;. The entrypoint still added &lt;code&gt;--auth&lt;/code&gt; (the process ran as &lt;code&gt;mongod --auth --bind_ip_all&lt;/code&gt;), but it did not create the user, because the volume was not empty. Every login failed with &lt;code&gt;UserNotFound: Could not find user "root" for db "admin"&lt;/code&gt;, and queries without credentials failed with &lt;code&gt;Command aggregate requires authentication&lt;/code&gt;. MongoDB's &lt;a href="https://www.mongodb.com/docs/manual/core/localhost-exception/" rel="noopener noreferrer"&gt;localhost exception&lt;/a&gt; "allows you to create the first user or role in the system after enabling access control." It only works from inside the container, so the fix ran there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;mongolate-mongo-1 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'mongosh --quiet admin --eval "db.createUser({user: process.env.MONGO_INITDB_ROOT_USERNAME, pwd: process.env.MONGO_INITDB_ROOT_PASSWORD, roles: [\"root\"]}).ok"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;1&lt;/code&gt;, and the root login worked. The old data was still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix docker-entrypoint-initdb.d scripts that don't run
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create the app user in the right database
&lt;/h3&gt;

&lt;p&gt;Most apps should not log in as root. The usual pattern is a script in &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; that creates an app user. The lab mounted this file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Runs once, against MONGO_INITDB_DATABASE, only when /data/db is empty&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;readWrite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appdb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seeded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;MONGO_INITDB_DATABASE: appdb&lt;/code&gt; and &lt;code&gt;APP_DB_PASSWORD&lt;/code&gt; passed into the &lt;code&gt;mongo&lt;/code&gt; service, the log showed &lt;code&gt;running /docker-entrypoint-initdb.d/01-create-app-user.js&lt;/code&gt; and &lt;code&gt;MongoDB init process complete; ready for start up.&lt;/code&gt; The &lt;code&gt;.js&lt;/code&gt; file read the password with &lt;code&gt;process.env&lt;/code&gt;, so it never sat in the script.&lt;/p&gt;

&lt;p&gt;Now the &lt;code&gt;authSource&lt;/code&gt; rule flips. The script ran in &lt;code&gt;appdb&lt;/code&gt;, so the &lt;code&gt;app&lt;/code&gt; user lives in &lt;code&gt;appdb&lt;/code&gt;, not in &lt;code&gt;admin&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;client$ mongosh 'mongodb://app:App-Pass-5532@mongo:27017/appdb?authSource=admin' --eval 'db.items.countDocuments()'
MongoServerError: Authentication failed.
client$ mongosh 'mongodb://app:App-Pass-5532@mongo:27017/appdb' --eval 'db.items.countDocuments()'
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copying &lt;code&gt;?authSource=admin&lt;/code&gt; from a root example into an app user's connection string breaks it. Set &lt;code&gt;authSource&lt;/code&gt; to the database the script ran in, or leave it out when that database is already in the path. The &lt;code&gt;readWrite&lt;/code&gt; role on &lt;code&gt;appdb&lt;/code&gt; also limits the user. A write to another database failed with &lt;code&gt;MongoServerError: not authorized on other to execute command&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MONGO_INITDB_DATABASE&lt;/code&gt; only picks the database the &lt;code&gt;.js&lt;/code&gt; scripts run against. The Docker Hub page notes that "MongoDB is fundamentally designed for "create on first use", so if you do not insert data with your JavaScript files, then no database is created." The &lt;code&gt;insertOne&lt;/code&gt; line in the script creates &lt;code&gt;appdb&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run a new script on an existing volume
&lt;/h3&gt;

&lt;p&gt;The lab then added &lt;code&gt;02-create-report-user.js&lt;/code&gt; and recreated the container on the same volume. The script did not run, and the user did not exist:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F04-docker-entrypoint-initdb-not-running-mongo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F04-docker-entrypoint-initdb-not-running-mongo.webp" alt="Terminal capture: on a fresh volume the init script runs and the app user works only without authSource=admin, then a second init script added later does not run on the existing volume until it is run by hand with mongosh as root" width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Init scripts follow the same rule as the root variables. They run once, on the first start of an empty &lt;code&gt;/data/db&lt;/code&gt;. You don't need to delete the volume to apply a new one. Run it with &lt;code&gt;mongosh&lt;/code&gt; inside the container, logged in as root, against the same database the entrypoint would use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;mongoinit-mongo-1 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'mongosh --quiet -u root -p "$MONGO_INITDB_ROOT_PASSWORD" \
  --authenticationDatabase admin appdb /docker-entrypoint-initdb.d/02-create-report-user.js'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the &lt;code&gt;report&lt;/code&gt; user logged in to &lt;code&gt;appdb&lt;/code&gt;. The entrypoint also runs &lt;code&gt;.sh&lt;/code&gt; files from the same folder, and ignores any other extension. On a fresh volume with no scripts, the log shows &lt;code&gt;ignoring /docker-entrypoint-initdb.d/*&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix MongoParseError for passwords with special characters
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Percent-encode the password in the connection string
&lt;/h3&gt;

&lt;p&gt;The lab set the root password to &lt;code&gt;p@ss:w/rd#1&lt;/code&gt;. The Compose value worked, because the entrypoint passes it to &lt;code&gt;createUser&lt;/code&gt; and never builds a URL. The connection string was the problem:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F05-mongodb-password-unescaped-characters-docker.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fmongodb-docker-authentication-failed%2F05-mongodb-password-unescaped-characters-docker.webp" alt="Terminal capture: passwords with @, colon and slash in a MongoDB connection string fail with MongoRuntimeError Unable to parse and MongoParseError Password contains unescaped characters, and the percent-encoded password p%40ss%3Aw%2Frd%231 works from another container and from the host through a published port" width="800" height="237"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client$ mongosh 'mongodb://root:p@ss:w/rd#1@mongo:27017/?authSource=admin' --eval 1
MongoRuntimeError: Unable to parse ss:w with URL
client$ mongosh 'mongodb://root:pa:ss@mongo:27017/?authSource=admin' --eval 1
MongoParseError: Password contains unescaped characters
client$ mongosh 'mongodb://root:pa@ss@mongo:27017/?authSource=admin' --eval 1
MongoParseError: Invalid connection string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MongoDB docs say that if the username or password includes &lt;code&gt;$ : / ? # [ ] @&lt;/code&gt;, "those characters must be converted using percent encoding". Python's &lt;code&gt;urllib.parse.quote("p@ss:w/rd#1", safe="")&lt;/code&gt; printed &lt;code&gt;p%40ss%3Aw%2Frd%231&lt;/code&gt;, and that version logged in. Put the encoded form only in the connection string. The &lt;code&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/code&gt; value stays as typed. If the password contains &lt;code&gt;$&lt;/code&gt;, Compose also treats it as a variable, which the &lt;a href="https://deploylark.com/posts/mysql-docker-access-denied-root/" rel="noopener noreferrer"&gt;MySQL guide&lt;/a&gt; covers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect from the host through a published port
&lt;/h3&gt;

&lt;p&gt;Without a &lt;code&gt;ports&lt;/code&gt; entry, nothing on the host can reach MongoDB. The lab published &lt;code&gt;127.0.0.1:27018:27017&lt;/code&gt;, and &lt;code&gt;mongosh&lt;/code&gt; with host networking connected to &lt;code&gt;127.0.0.1:27018&lt;/code&gt; and printed &lt;code&gt;9.0.4&lt;/code&gt;. Port 27017 on the host was refused, because only 27018 was published. Binding to &lt;code&gt;127.0.0.1&lt;/code&gt; keeps the port off the network, so only the host itself can connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A MongoDB Compose file that avoids these errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  compose.yaml, .env and the secret file
&lt;/h3&gt;

&lt;p&gt;This file ran verbatim in the lab:&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;mongo&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;mongo:9.0.4&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MONGO_INITDB_ROOT_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
      &lt;span class="na"&gt;MONGO_INITDB_ROOT_PASSWORD_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/secrets/mongo_root_password&lt;/span&gt;
      &lt;span class="na"&gt;MONGO_INITDB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appdb&lt;/span&gt;
      &lt;span class="na"&gt;APP_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mongo_root_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;mongo-data:/data/db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./init:/docker-entrypoint-initdb.d:ro&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mongosh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--quiet"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--eval"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db.adminCommand('ping').ok"&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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mongo:9.0.4&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;mongo&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MONGODB_URI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mongodb://app:${APP_DB_PASSWORD}@mongo:27017/appdb?authSource=appdb&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mongosh&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--quiet&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$$MONGODB_URI&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--eval&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'db.items.countDocuments()'"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mongo_root_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./secrets/mongo_root_password&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;mongo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APP_DB_PASSWORD=App-Pass-5532
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;./secrets/mongo_root_password&lt;/code&gt; holds the root password, and &lt;code&gt;./init/01-create-app-user.js&lt;/code&gt; is the script from above. The &lt;code&gt;_FILE&lt;/code&gt; suffix is documented on Docker Hub for both root variables. With it, &lt;code&gt;docker inspect&lt;/code&gt; on the container did not show the root password. Replace &lt;code&gt;app&lt;/code&gt; with your real service and give it the &lt;code&gt;MONGODB_URI&lt;/code&gt;. Keep &lt;code&gt;APP_DB_PASSWORD&lt;/code&gt; URL-safe or percent-encode it in the URI.&lt;/p&gt;

&lt;p&gt;The healthcheck only proves that &lt;code&gt;mongod&lt;/code&gt; answers, since &lt;code&gt;ping&lt;/code&gt; needs no login. It doesn't test the app's password. That is enough for &lt;code&gt;depends_on&lt;/code&gt;. A wrong app password still shows up in the app's own log as &lt;code&gt;Authentication failed.&lt;/code&gt; The &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami guide&lt;/a&gt; uses the same &lt;code&gt;service_healthy&lt;/code&gt; pattern with PostgreSQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check it
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;code&amp;gt;text&lt;br&gt;
$ docker compose up -d&lt;br&gt;
 Container mongofinal-mongo-1  Started&lt;br&gt;
 Container mongofinal-mongo-1  Healthy&lt;br&gt;
 Container mongofinal-app-1  Started&lt;br&gt;
$ docker compose ps -a --format '&amp;amp;#123;&amp;amp;#123;.Service&amp;amp;#125;&amp;amp;#125; &amp;amp;#123;&amp;amp;#123;.Status&amp;amp;#125;&amp;amp;#125;'&lt;br&gt;
app Exited (0) 7 seconds ago&lt;br&gt;
mongo Up 18 seconds (healthy)&lt;br&gt;
$ docker compose logs --no-log-prefix app&lt;br&gt;
1&lt;br&gt;
$ docker compose restart mongo&lt;br&gt;
$ docker compose run --rm app&lt;br&gt;
1&lt;br&gt;
&amp;lt;/code&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The log showed &lt;code&gt;running /docker-entrypoint-initdb.d/01-create-app-user.js&lt;/code&gt; once, on the first start. Root logged in with the password from the secret file and &lt;code&gt;?authSource=admin&lt;/code&gt;. After the restart, the seeded document was still there. Keep the image pinned to an exact version such as &lt;code&gt;mongo:9.0.4&lt;/code&gt;, so a restart never pulls a new major version onto your data volume. The &lt;a href="https://deploylark.com/posts/postgres-docker-upgrade-17-to-18/" rel="noopener noreferrer"&gt;PostgreSQL 17 to 18 guide&lt;/a&gt; shows what a planned major upgrade of a database volume looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Login errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does MongoDB in Docker say Authentication failed when the password is right?
&lt;/h4&gt;

&lt;p&gt;Check which database the user lives in. The root user from &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; is in &lt;code&gt;admin&lt;/code&gt;, so a URI like &lt;code&gt;mongodb://root:pass@mongo:27017/appdb&lt;/code&gt; needs &lt;code&gt;?authSource=admin&lt;/code&gt;. Run &lt;code&gt;docker logs&lt;/code&gt; on the MongoDB container. &lt;code&gt;UserNotFound&lt;/code&gt; points to the database, &lt;code&gt;storedKey mismatch&lt;/code&gt; to the password.&lt;/p&gt;

&lt;h4&gt;
  
  
  What is the default username and password for the MongoDB Docker image?
&lt;/h4&gt;

&lt;p&gt;There is none. Without &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; and &lt;code&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/code&gt;, the lab's server ran without &lt;code&gt;--auth&lt;/code&gt;, logged "Access control is not enabled for the database" and accepted writes without a login. If you set only one of the two, the container exits with &lt;code&gt;error: missing 'MONGO_INITDB_ROOT_USERNAME' or 'MONGO_INITDB_ROOT_PASSWORD'&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I connect to MongoDB in Docker from the host?
&lt;/h4&gt;

&lt;p&gt;Publish the port, for example &lt;code&gt;ports: ["127.0.0.1:27017:27017"]&lt;/code&gt;, and connect to &lt;code&gt;127.0.0.1&lt;/code&gt; with &lt;code&gt;?authSource=admin&lt;/code&gt; for the root user. Without a published port, the host can't reach the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup and init scripts
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why are my docker-entrypoint-initdb.d scripts not running?
&lt;/h4&gt;

&lt;p&gt;The volume already holds a database. The entrypoint runs the scripts only when &lt;code&gt;/data/db&lt;/code&gt; has no data files. Run the script with &lt;code&gt;mongosh&lt;/code&gt; inside the container, or delete the volume if the data doesn't matter. Only &lt;code&gt;.js&lt;/code&gt; and &lt;code&gt;.sh&lt;/code&gt; files run.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I create a database and user for a MongoDB container on startup?
&lt;/h4&gt;

&lt;p&gt;Set &lt;code&gt;MONGO_INITDB_DATABASE&lt;/code&gt; and mount a &lt;code&gt;.js&lt;/code&gt; file into &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; that calls &lt;code&gt;db.createUser()&lt;/code&gt; and inserts one document. MongoDB creates a database on the first write. The app then connects to that database without &lt;code&gt;authSource=admin&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I change the MongoDB root password in Docker?
&lt;/h4&gt;

&lt;p&gt;Changing &lt;code&gt;MONGO_INITDB_ROOT_PASSWORD&lt;/code&gt; has no effect on an existing volume. Log in with the old password and run &lt;code&gt;db.getSiblingDB("admin").changeUserPassword("root", "new-password")&lt;/code&gt;, then update &lt;code&gt;.env&lt;/code&gt; to match.&lt;/p&gt;

</description>
      <category>mongodb</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>PostgreSQL in Docker: Fix Password Authentication Failed</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:32:53 +0000</pubDate>
      <link>https://dev.to/deploylark/postgresql-in-docker-fix-password-authentication-failed-57j5</link>
      <guid>https://dev.to/deploylark/postgresql-in-docker-fix-password-authentication-failed-57j5</guid>
      <description>&lt;p&gt;Your Postgres container runs, but every login fails with &lt;code&gt;password authentication failed for user "postgres"&lt;/code&gt;. Or &lt;code&gt;psql&lt;/code&gt; inside the container says &lt;code&gt;role "root" does not exist&lt;/code&gt;. Or the container exits right away with &lt;code&gt;Database is uninitialized and superuser password is not specified&lt;/code&gt;. The password in your &lt;code&gt;.env&lt;/code&gt; file looks right, and that is the trap.&lt;/p&gt;

&lt;p&gt;This guide reproduces each of these errors with the official &lt;code&gt;postgres:18.6&lt;/code&gt; image in Docker Compose and fixes them. Every error message below comes from that lab run on November 3, 2026, with a small Node.js client (&lt;code&gt;pg&lt;/code&gt; 8.23.1) standing in for your app. Most of them have one root cause: the image reads its &lt;code&gt;POSTGRES_*&lt;/code&gt; variables only once, the first time it creates the database.&lt;/p&gt;

&lt;p&gt;These questions get a lot of traffic. On Stack Overflow, &lt;a href="https://stackoverflow.com/questions/37694987/connecting-to-postgresql-in-a-docker-container-from-outside" rel="noopener noreferrer"&gt;Connecting to Postgresql in a docker container from outside&lt;/a&gt; had 986,699 views on November 3, 2026, and &lt;a href="https://stackoverflow.com/questions/60193781/postgres-with-docker-compose-gives-fatal-role-root-does-not-exist-error" rel="noopener noreferrer"&gt;postgres with docker compose gives FATAL: role "root" does not exist error&lt;/a&gt; had 188,086.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Changing &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; after the first start does nothing. The log says &lt;code&gt;Skipping initialization&lt;/code&gt;. Change the password with &lt;code&gt;ALTER ROLE ... PASSWORD&lt;/code&gt; through &lt;code&gt;docker compose exec&lt;/code&gt;, or delete the volume if the data is disposable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;role "root" does not exist&lt;/code&gt; means &lt;code&gt;psql&lt;/code&gt; or &lt;code&gt;pg_isready&lt;/code&gt; ran without &lt;code&gt;-U&lt;/code&gt;. Pass &lt;code&gt;-U&lt;/code&gt; with the user from &lt;code&gt;POSTGRES_USER&lt;/code&gt;. If you set &lt;code&gt;POSTGRES_USER&lt;/code&gt;, there is no &lt;code&gt;postgres&lt;/code&gt; role at all.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;superuser password is not specified&lt;/code&gt; means the password reached the container empty. Check for an unset &lt;code&gt;.env&lt;/code&gt; variable or a &lt;code&gt;$&lt;/code&gt; in the password, and use &lt;code&gt;${DB_PASSWORD:?}&lt;/code&gt; so Compose stops before it starts anything.&lt;/li&gt;
&lt;li&gt;From another container, connect to the service name, not &lt;code&gt;localhost&lt;/code&gt;, and wait for a healthcheck with &lt;code&gt;condition: service_healthy&lt;/code&gt;. URL-encode special characters in connection strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix &lt;code&gt;password authentication failed&lt;/code&gt; after changing POSTGRES_PASSWORD
&lt;/h2&gt;

&lt;p&gt;You started the stack once, then changed the password in &lt;code&gt;.env&lt;/code&gt; or &lt;code&gt;compose.yaml&lt;/code&gt;, recreated the container, and now nothing can log in with the new password.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the new password never applied
&lt;/h3&gt;

&lt;p&gt;The official image runs &lt;code&gt;initdb&lt;/code&gt; only when the data directory is empty. The &lt;a href="https://github.com/docker-library/docs/blob/master/postgres/content.md" rel="noopener noreferrer"&gt;image documentation&lt;/a&gt; says it plainly: "the Docker specific variables will only have an effect if you start the container with a data directory that is empty." An existing database is left as it is.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F01-postgres-docker-init-only-on-empty-volume-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F01-postgres-docker-init-only-on-empty-volume-diagram.webp" alt="Diagram: when the postgres container starts, the entrypoint checks if the data directory is empty. If yes, initdb creates POSTGRES_USER with its password, creates POSTGRES_DB and runs the initdb.d scripts. If no, it prints Skipping initialization and ignores the POSTGRES variables and scripts" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the lab, the volume was first created with &lt;code&gt;DB_PASSWORD=first-secret&lt;/code&gt;. After changing &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;second-secret&lt;/code&gt; and running &lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;, the log showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PostgreSQL Database directory appears to contain a database; Skipping initialization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container's environment had the new value, &lt;code&gt;POSTGRES_PASSWORD=second-secret&lt;/code&gt;. The database still had the old one. A login with the new password failed with &lt;code&gt;password authentication failed for user "postgres"&lt;/code&gt; (SQLSTATE &lt;code&gt;28P01&lt;/code&gt;), and the server log named the &lt;code&gt;pg_hba.conf&lt;/code&gt; rule the connection matched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  password authentication failed for user "postgres"
DETAIL:  Connection matched file "/var/lib/postgresql/18/docker/pg_hba.conf" line 128: "host all all all scram-sha-256"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule explains why init scripts seem to be ignored. A file mounted into &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; after the first start never ran, and &lt;code&gt;select count(*) from notes&lt;/code&gt; returned &lt;code&gt;relation "notes" does not exist&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change the password inside the database
&lt;/h3&gt;

&lt;p&gt;The image's &lt;code&gt;pg_hba.conf&lt;/code&gt; trusts connections over the local Unix socket, so &lt;code&gt;docker compose exec&lt;/code&gt; gets you in without the password. Set the new password with SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ALTER ROLE postgres WITH PASSWORD 'second-secret'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;ALTER ROLE&lt;/code&gt;. The app logged in with &lt;code&gt;second-secret&lt;/code&gt; right away, without a restart, and &lt;code&gt;first-secret&lt;/code&gt; stopped working. Keep &lt;code&gt;.env&lt;/code&gt; and the database in sync from now on, because the app reads the new value from &lt;code&gt;.env&lt;/code&gt; while the database only changes through SQL.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F02-postgres-password-authentication-failed-after-change.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F02-postgres-password-authentication-failed-after-change.webp" alt="Terminal capture: after changing DB_PASSWORD in .env, the postgres log says Skipping initialization, the container environment shows the new password, the login fails with password authentication failed for user postgres and a pg_hba.conf scram-sha-256 detail line, and ALTER ROLE fixes it" width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the data is disposable, delete the volume instead. &lt;code&gt;docker compose down -v&lt;/code&gt; removes the project's volumes, and with them all data. On the next &lt;code&gt;up&lt;/code&gt;, the log showed &lt;code&gt;running /docker-entrypoint-initdb.d/01-notes.sql&lt;/code&gt;, the &lt;code&gt;notes&lt;/code&gt; table existed, and the new password worked. The &lt;a href="https://deploylark.com/posts/mysql-docker-access-denied-root/" rel="noopener noreferrer"&gt;MySQL&lt;/a&gt; and &lt;a href="https://deploylark.com/posts/mongodb-docker-authentication-failed/" rel="noopener noreferrer"&gt;MongoDB&lt;/a&gt; guides cover the same first-start rule for those images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix &lt;code&gt;role "root" does not exist&lt;/code&gt; and &lt;code&gt;role "postgres" does not exist&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The second family of errors comes from the user name, not the password.&lt;/p&gt;

&lt;h3&gt;
  
  
  psql and pg_isready inside the container
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker compose exec db psql&lt;/code&gt; runs as root inside the container. Without &lt;code&gt;-U&lt;/code&gt;, psql uses that operating system user as the database user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL:  role "root" does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top answer on the Stack Overflow question, by richyen, puts it this way: "If you don't specify the PGUSER environment variable, then psql will assume you want to use the current OS user as your database user name." Pass the user, as in &lt;code&gt;psql -U postgres&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same thing happens in healthchecks. With &lt;code&gt;test: ["CMD", "pg_isready"]&lt;/code&gt;, the container still showed &lt;code&gt;(healthy)&lt;/code&gt;, because &lt;code&gt;pg_isready&lt;/code&gt; reported the server as up even though the role was rejected. But the server log got a new &lt;code&gt;FATAL:  role "root" does not exist&lt;/code&gt; line every 5 seconds, one per check. With &lt;code&gt;["CMD", "pg_isready", "-U", "app", "-d", "app"]&lt;/code&gt;, the log had 0 FATAL lines over the next 20 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  POSTGRES_USER replaces the postgres role
&lt;/h3&gt;

&lt;p&gt;When you set &lt;code&gt;POSTGRES_USER&lt;/code&gt;, the image creates that user as the superuser and does not create &lt;code&gt;postgres&lt;/code&gt;. With &lt;code&gt;POSTGRES_USER: app&lt;/code&gt; and no &lt;code&gt;POSTGRES_DB&lt;/code&gt;, the lab cluster had one role, &lt;code&gt;app&lt;/code&gt; (Superuser, Create role, Create DB, Replication, Bypass RLS), and a database also named &lt;code&gt;app&lt;/code&gt;, matching the docs: the variable creates "the specified user with superuser power and a database with the same name."&lt;/p&gt;

&lt;p&gt;So these failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker compose exec db psql -U postgres
... FATAL:  role "postgres" does not exist
$ docker compose exec db psql -U app -d appdb
... FATAL:  database "appdb" does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over TCP, the error is less helpful. Logging in as &lt;code&gt;postgres&lt;/code&gt; from the app with the &lt;code&gt;app&lt;/code&gt; password returned &lt;code&gt;password authentication failed for user "postgres"&lt;/code&gt;, which sends you after the wrong problem. Only the server log told the truth: &lt;code&gt;DETAIL:  Role "postgres" does not exist.&lt;/code&gt; Check the server log before you reset any password.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F03-postgres-role-root-does-not-exist.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F03-postgres-role-root-does-not-exist.webp" alt="Terminal capture: with POSTGRES_USER set to app, psql without -U fails with role root does not exist, psql -U postgres fails with role postgres does not exist, a TCP login as postgres reports password authentication failed with a server detail that the role does not exist, and a pg_isready healthcheck without -U logs FATAL lines until -U app -d app is added" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Use the user and database you set, in &lt;code&gt;psql&lt;/code&gt;, in the healthcheck and in the app's connection string. If an app insists on a &lt;code&gt;postgres&lt;/code&gt; role or another database, create them with SQL: &lt;code&gt;CREATE ROLE postgres WITH LOGIN SUPERUSER PASSWORD '...'&lt;/code&gt; and &lt;code&gt;createdb -U app appdb&lt;/code&gt; both worked in the lab, and the app then logged in to &lt;code&gt;appdb&lt;/code&gt; as &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix &lt;code&gt;Database is uninitialized and superuser password is not specified&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one stops the container on its first start. It exited with code 1 and printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Database is uninitialized and superuser password is not specified.
       You must specify POSTGRES_PASSWORD to a non-empty value for the
       superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message goes on to offer &lt;code&gt;POSTGRES_HOST_AUTH_METHOD=trust&lt;/code&gt;, which lets anyone connect without a password. Don't use that to get past this error. If you did set a password, check how it gets into the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unset .env variables and dollar signs
&lt;/h3&gt;

&lt;p&gt;In the lab, the compose file had &lt;code&gt;POSTGRES_PASSWORD: ${DB_PASSWORD}&lt;/code&gt; and there was no &lt;code&gt;.env&lt;/code&gt; file next to it. Compose printed a warning and started the container anyway, with an empty password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;level=warning msg="The \"DB_PASSWORD\" variable is not set. Defaulting to a blank string."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;$&lt;/code&gt; inside a password does something similar. Compose treats it as the start of a variable. With &lt;code&gt;POSTGRES_PASSWORD: pa$word42&lt;/code&gt; written straight into &lt;code&gt;compose.yaml&lt;/code&gt;, Compose warned that &lt;code&gt;word42&lt;/code&gt; was not set, and the container got &lt;code&gt;POSTGRES_PASSWORD=pa&lt;/code&gt;. The cluster was created with the password &lt;code&gt;pa&lt;/code&gt;, so &lt;code&gt;pa$word42&lt;/code&gt; failed with &lt;code&gt;password authentication failed&lt;/code&gt; and &lt;code&gt;pa&lt;/code&gt; worked. The &lt;a href="https://docs.docker.com/reference/compose-file/interpolation/" rel="noopener noreferrer"&gt;Compose interpolation docs&lt;/a&gt; give the fix: "You can use a $$ (double-dollar sign) when your configuration needs a literal dollar sign." With &lt;code&gt;pa$$word42&lt;/code&gt;, the container got &lt;code&gt;pa$word42&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F04-postgres-superuser-password-not-specified.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F04-postgres-superuser-password-not-specified.webp" alt="Terminal capture: with DB_PASSWORD unset Compose warns and the postgres container exits with Database is uninitialized and superuser password is not specified, a dollar sign in the password is cut to pa until it is written as two dollar signs, and the colon question mark syntax makes Compose stop with an error before starting anything" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Make Compose fail before it starts anything
&lt;/h3&gt;

&lt;p&gt;Replace &lt;code&gt;${DB_PASSWORD}&lt;/code&gt; with &lt;code&gt;${DB_PASSWORD:?set DB_PASSWORD in .env}&lt;/code&gt;. With no &lt;code&gt;.env&lt;/code&gt; file, &lt;code&gt;docker compose up -d&lt;/code&gt; stopped right away and started nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable DB_PASSWORD is missing a value: set DB_PASSWORD in .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters more than it looks. An empty password stops Postgres on the first start, but the &lt;code&gt;pa&lt;/code&gt; case above did not stop anything. It created a database with the wrong password, and the variable rule from the first section then kept that password for good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix connection refused and pg_hba errors from other containers
&lt;/h2&gt;

&lt;p&gt;If the password and user are right and the app still can't connect, look at the address, the timing and the connection string.&lt;/p&gt;

&lt;h3&gt;
  
  
  localhost, published ports and startup order
&lt;/h3&gt;

&lt;p&gt;Inside a container, &lt;code&gt;localhost&lt;/code&gt; is that container. An app with &lt;code&gt;@localhost:5432&lt;/code&gt; in its URL got &lt;code&gt;connect ECONNREFUSED ::1:5432, connect ECONNREFUSED 127.0.0.1:5432&lt;/code&gt;. Use the Compose service name, such as &lt;code&gt;db&lt;/code&gt;, as the host. &lt;a href="https://node-postgres.com/features/connecting" rel="noopener noreferrer"&gt;node-postgres&lt;/a&gt; defaults to &lt;code&gt;PGHOST=localhost&lt;/code&gt; when you set no host, so always set it.&lt;/p&gt;

&lt;p&gt;From the host machine, the container is only reachable if you publish the port. Without &lt;code&gt;ports&lt;/code&gt;, &lt;code&gt;psql -h 127.0.0.1 -p 5432&lt;/code&gt; on the host got &lt;code&gt;Connection refused&lt;/code&gt;. With &lt;code&gt;ports: - "127.0.0.1:5433:5432"&lt;/code&gt;, the host connected on port 5433 and read the data. Binding to &lt;code&gt;127.0.0.1&lt;/code&gt; keeps the port off the host's other network interfaces.&lt;/p&gt;

&lt;p&gt;Timing is the other trap. With &lt;code&gt;depends_on: - db&lt;/code&gt; and no condition, the app started as soon as the database container did. On a fresh volume with an init script, it failed with &lt;code&gt;connect ECONNREFUSED 172.28.0.2:5432&lt;/code&gt; and exited. During the first start, the entrypoint runs a temporary server with &lt;code&gt;listen_addresses=''&lt;/code&gt;, so nothing listens on TCP until the init scripts are done. Starting the app again a few seconds later worked. The fix is a healthcheck plus &lt;code&gt;condition: service_healthy&lt;/code&gt;, shown in the Compose file below. The &lt;a href="https://deploylark.com/posts/grafana-prometheus-docker-permission-denied/" rel="noopener noreferrer"&gt;Grafana and Prometheus guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/redis-docker-connection-refused-noauth-misconf/" rel="noopener noreferrer"&gt;Redis guide&lt;/a&gt; run into the same &lt;code&gt;localhost&lt;/code&gt; mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  Passwords in URLs and pg_hba.conf entries
&lt;/h3&gt;

&lt;p&gt;Connection strings are URLs, so &lt;code&gt;@&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;:&lt;/code&gt; in a password break them. With the password &lt;code&gt;p@ss#word/1&lt;/code&gt; pasted into &lt;code&gt;postgres://app:p@ss#word/1@db:5432/app&lt;/code&gt;, the parser read the password as &lt;code&gt;p&lt;/code&gt;, the host as &lt;code&gt;ss&lt;/code&gt; and no database at all. Encode the password first. &lt;code&gt;node -p 'encodeURIComponent("p@ss#word/1")'&lt;/code&gt; printed &lt;code&gt;p%40ss%23word%2F1&lt;/code&gt;, and the URL with that value logged in. Or skip the URL and pass &lt;code&gt;PGHOST&lt;/code&gt;, &lt;code&gt;PGUSER&lt;/code&gt;, &lt;code&gt;PGPASSWORD&lt;/code&gt; and &lt;code&gt;PGDATABASE&lt;/code&gt; as separate variables, as the Compose file below does.&lt;/p&gt;

&lt;p&gt;You only see &lt;code&gt;no pg_hba.conf entry&lt;/code&gt; if you replaced the image's default &lt;code&gt;pg_hba.conf&lt;/code&gt;, which ends with &lt;code&gt;host all all all scram-sha-256&lt;/code&gt; and allows every address with a password. A custom file that only allowed &lt;code&gt;127.0.0.1/32&lt;/code&gt; over TCP rejected the app container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no pg_hba.conf entry for host "172.28.0.4", user "postgres", database "postgres", no encryption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a line for the Docker network. The &lt;a href="https://www.postgresql.org/docs/18/auth-pg-hba-conf.html" rel="noopener noreferrer"&gt;pg_hba.conf docs&lt;/a&gt; describe &lt;code&gt;samenet&lt;/code&gt; as "any address in any subnet that the server is directly connected to", which covers the Compose network. After adding &lt;code&gt;host  all all samenet scram-sha-256&lt;/code&gt; and running &lt;code&gt;SELECT pg_reload_conf()&lt;/code&gt;, the same login worked without a restart. If you carried a custom file over from an upgrade, the &lt;a href="https://deploylark.com/posts/postgres-docker-upgrade-17-to-18/" rel="noopener noreferrer"&gt;PostgreSQL 17 to 18 upgrade guide&lt;/a&gt; covers the other places it can break.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F05-postgres-connection-refused-pg-hba-docker.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fpostgres-docker-password-authentication-failed%2F05-postgres-connection-refused-pg-hba-docker.webp" alt="Terminal capture: the app gets ECONNREFUSED while the database runs its init script, localhost inside the app container is refused, an unencoded password is parsed as host ss, the encodeURIComponent value logs in, and a custom pg_hba.conf returns no pg_hba.conf entry for host until a samenet line is added" width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL Compose file that avoids these errors
&lt;/h2&gt;

&lt;p&gt;This file puts the fixes together. It ran verbatim in the lab. The &lt;code&gt;app&lt;/code&gt; service is the small Node.js test client from the lab. Replace its image, command and volume with your own app and keep the environment and &lt;code&gt;depends_on&lt;/code&gt; parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compose file
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt; next to it with a random password. Hex characters need no escaping in Compose or in URLs:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'DB_PASSWORD=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18.6&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&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;app&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;${DB_PASSWORD:?set DB_PASSWORD in .env}&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;pgdata:/var/lib/postgresql&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./initdb:/docker-entrypoint-initdb.d:ro&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-U"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-d"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;start_period&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:24.22.2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node pgtest.mjs&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;PGHOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;PGUSER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;PGDATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:?set DB_PASSWORD in .env}&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;./app:/app:ro&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;db&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The volume is mounted at &lt;code&gt;/var/lib/postgresql&lt;/code&gt;, the path that the 18 images expect. Both services read the same &lt;code&gt;DB_PASSWORD&lt;/code&gt;, so there is one place to change it. If you change it later, also run the &lt;code&gt;ALTER ROLE&lt;/code&gt; command from the first section, or the app gets &lt;code&gt;28P01&lt;/code&gt; again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check that it works
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;docker compose up -d&lt;/code&gt;. Compose waited for the database before it started the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container pgfinal-db-1  Waiting
 Container pgfinal-db-1  Healthy
 Container pgfinal-app-1  Starting
 Container pgfinal-app-1  Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app printed &lt;code&gt;OK app app PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2)&lt;/code&gt;, and the log showed &lt;code&gt;running /docker-entrypoint-initdb.d/01-schema.sql&lt;/code&gt;. After &lt;code&gt;docker compose restart db&lt;/code&gt; and &lt;code&gt;docker compose run --rm app&lt;/code&gt;, the app connected again, and the database log had 0 FATAL lines for the whole run. For a full app stack on this pattern, see the &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami with PostgreSQL guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Passwords and users
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does Postgres in Docker say password authentication failed when the password is right?
&lt;/h4&gt;

&lt;p&gt;Either the database still has the password from its first start, or the user doesn't exist. Look at the server log with &lt;code&gt;docker compose logs db&lt;/code&gt;. &lt;code&gt;Skipping initialization&lt;/code&gt; at startup means your new &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; was ignored. &lt;code&gt;DETAIL:  Role "..." does not exist.&lt;/code&gt; means the user name is wrong.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I use a new password from .env without removing the volume?
&lt;/h4&gt;

&lt;p&gt;Run &lt;code&gt;docker compose exec db psql -U &amp;lt;user&amp;gt; -c "ALTER ROLE &amp;lt;user&amp;gt; WITH PASSWORD '&amp;lt;new&amp;gt;'"&lt;/code&gt;. It works over the local socket without the old password and takes effect right away. Then keep the same value in &lt;code&gt;.env&lt;/code&gt; for the app.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why do I get role "root" does not exist?
&lt;/h4&gt;

&lt;p&gt;Something ran &lt;code&gt;psql&lt;/code&gt; or &lt;code&gt;pg_isready&lt;/code&gt; inside the container without &lt;code&gt;-U&lt;/code&gt;, so it used the operating system user &lt;code&gt;root&lt;/code&gt;. Add &lt;code&gt;-U&lt;/code&gt; with your &lt;code&gt;POSTGRES_USER&lt;/code&gt; (and &lt;code&gt;-d&lt;/code&gt; with your database) to the command or the healthcheck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup and connections
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I connect to Postgres in Docker from the host?
&lt;/h4&gt;

&lt;p&gt;Publish the port, for example &lt;code&gt;ports: - "127.0.0.1:5433:5432"&lt;/code&gt;, then connect to &lt;code&gt;127.0.0.1&lt;/code&gt; on port 5433 with the user and password from your Compose file. Other containers on the same network use the service name and port 5432 and need no published port.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why didn't my docker-entrypoint-initdb.d script run?
&lt;/h4&gt;

&lt;p&gt;Init scripts run only when the data directory is empty, the same as the &lt;code&gt;POSTGRES_*&lt;/code&gt; variables. If the volume already has a database, the scripts are skipped. Run the SQL by hand with &lt;code&gt;docker compose exec&lt;/code&gt;, or delete the volume if the data is disposable.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I fix Database is uninitialized and superuser password is not specified?
&lt;/h4&gt;

&lt;p&gt;Give &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; a non-empty value. Check that your &lt;code&gt;.env&lt;/code&gt; file sits next to &lt;code&gt;compose.yaml&lt;/code&gt; and defines the variable, and run &lt;code&gt;docker compose config&lt;/code&gt; to see the value Compose actually passes. Use &lt;code&gt;${DB_PASSWORD:?}&lt;/code&gt; so a missing value stops Compose with an error.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>RabbitMQ in Docker: Fix ACCESS_REFUSED and Connection Refused</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:32:09 +0000</pubDate>
      <link>https://dev.to/deploylark/rabbitmq-in-docker-fix-accessrefused-and-connection-refused-41ea</link>
      <guid>https://dev.to/deploylark/rabbitmq-in-docker-fix-accessrefused-and-connection-refused-41ea</guid>
      <description>&lt;p&gt;You add &lt;code&gt;rabbitmq&lt;/code&gt; to a Compose file, point Celery or Spring AMQP at &lt;code&gt;amqp://guest:guest@localhost:5672/&lt;/code&gt;, and the client dies with &lt;code&gt;Connection refused&lt;/code&gt;. Or the TCP path works and you still get &lt;code&gt;ACCESS_REFUSED - Login was refused using authentication mechanism PLAIN&lt;/code&gt;. The broker log may say &lt;code&gt;user 'guest' can only connect via localhost&lt;/code&gt;, or &lt;code&gt;invalid credentials&lt;/code&gt;, or nothing useful while your new &lt;code&gt;RABBITMQ_DEFAULT_PASS&lt;/code&gt; is ignored.&lt;/p&gt;

&lt;p&gt;This guide reproduces those failures with &lt;code&gt;rabbitmq:4.4.0-management-alpine&lt;/code&gt; and a small Pika 1.3.2 client on the same Compose network, then fixes them. Every error string and log line below comes from that lab run on November 6, 2026. The Stack Overflow question &lt;a href="https://stackoverflow.com/questions/26811924/spring-amqp-rabbitmq-3-3-5-access-refused-login-was-refused-using-authentica" rel="noopener noreferrer"&gt;ACCESS_REFUSED - Login was refused using authentication mechanism PLAIN&lt;/a&gt; had 370,994 views that day, &lt;a href="https://stackoverflow.com/questions/53031439/connecting-to-rabbitmq-container-with-docker-compose" rel="noopener noreferrer"&gt;Connecting to RabbitMQ container with docker-compose&lt;/a&gt; had 50,479, and &lt;a href="https://stackoverflow.com/questions/55086050/rabbitmq-docker-connection-refused-on-port-5672" rel="noopener noreferrer"&gt;Rabbitmq - Docker connection refused on port 5672&lt;/a&gt; had 35,539.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From another container, &lt;code&gt;localhost&lt;/code&gt; is that container. Use the Compose service name, for example &lt;code&gt;rabbitmq&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ACCESS_REFUSED&lt;/code&gt; with PLAIN is the same client text for wrong passwords and for the guest loopback rule. Read the broker log line under it.&lt;/li&gt;
&lt;li&gt;The official Docker image sets &lt;code&gt;loopback_users.guest = false&lt;/code&gt;, so guest from a peer container often works. Bare-metal RabbitMQ and a restored loopback config refuse guest from peers.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;RABBITMQ_DEFAULT_USER&lt;/code&gt; and &lt;code&gt;RABBITMQ_DEFAULT_PASS&lt;/code&gt; on first boot. Changing them later does nothing if the data volume already exists.&lt;/li&gt;
&lt;li&gt;Wait for a real healthcheck before starting the app. &lt;code&gt;depends_on&lt;/code&gt; alone is not enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Fix Connection refused to port 5672
&lt;/h2&gt;

&lt;p&gt;Start with the map. Match the message you see to the cause and the fix:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F01-rabbitmq-docker-error-cause-fix-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F01-rabbitmq-docker-error-cause-fix-diagram.webp" alt="Table diagram mapping RabbitMQ Docker errors to causes and fixes: Connection refused on localhost or before ready, ACCESS_REFUSED guest can only connect via localhost, and ACCESS_REFUSED invalid credentials with a stale volume" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't connect to localhost from the app container
&lt;/h3&gt;

&lt;p&gt;The lab started with stock RabbitMQ and no custom user:&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;rabbitmq&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;rabbitmq:4.4.0-management-alpine&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rabbitmq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Pika client on the same Compose network played the app. With &lt;code&gt;RMQ_HOST=localhost&lt;/code&gt; it never reached RabbitMQ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client hostname='b1ce59b7a70e'
connecting host='localhost' port=5672 user='guest' vhost='/'
tcp connect to localhost:5672 failed: [Errno 111] Connection refused
FAIL AMQPConnectionError: ConnectionRefusedError(111, 'Connection refused')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F02-rabbitmq-docker-connection-refused-localhost.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F02-rabbitmq-docker-connection-refused-localhost.webp" alt="Terminal capture: Pika client in an app container gets Connection refused on localhost:5672, and the same refused error when it connects to rabbitmq before the listener is ready" width="798" height="165"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each container has its own loopback interface, so &lt;code&gt;localhost&lt;/code&gt; inside the app is the app. RabbitMQ listens in the &lt;code&gt;rabbitmq&lt;/code&gt; service. Set the host to &lt;code&gt;rabbitmq&lt;/code&gt; (or whatever you named the service), for example &lt;code&gt;amqp://labuser:secret@rabbitmq:5672/&lt;/code&gt;. The &lt;a href="https://deploylark.comhttps://deploylark.com/posts/redis-docker-connection-refused-noauth-misconf/" rel="noopener noreferrer"&gt;Redis guide&lt;/a&gt; and the &lt;a href="https://deploylark.comhttps://deploylark.com/posts/mysql-docker-access-denied-root/" rel="noopener noreferrer"&gt;MySQL guide&lt;/a&gt; show the same mistake for those databases.&lt;/p&gt;

&lt;p&gt;From the Docker host itself you also need a published port. Without &lt;code&gt;ports&lt;/code&gt;, a host tool cannot reach 5672. Add &lt;code&gt;ports: ["127.0.0.1:5672:5672"]&lt;/code&gt; if a process on the host must talk AMQP. Other Compose services do not need that mapping.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wait until the listener is ready
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;depends_on: [rabbitmq]&lt;/code&gt; only waits for the container process to start. In the lab, an immediate connect right after &lt;code&gt;docker compose up -d&lt;/code&gt; still got &lt;code&gt;Connection refused&lt;/code&gt; on &lt;code&gt;rabbitmq:5672&lt;/code&gt;. After the healthcheck flipped to healthy, the same client opened AMQP and published a message. Use a healthcheck and &lt;code&gt;condition: service_healthy&lt;/code&gt;, the pattern also used in the &lt;a href="https://deploylark.comhttps://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami guide&lt;/a&gt; with PostgreSQL:&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;rabbitmq&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;rabbitmq:4.4.0-management-alpine&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rabbitmq-diagnostics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;12&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;15s&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app:tag&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;rabbitmq&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix ACCESS_REFUSED for the guest user
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Read the broker log, not only the client text
&lt;/h3&gt;

&lt;p&gt;The client message is almost always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ACCESS_REFUSED - Login was refused using authentication mechanism PLAIN. For details see the broker logfile.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one string covers several causes. The broker log names the real one. With a wrong password the lab logged &lt;code&gt;PLAIN login refused: user 'guest' - invalid credentials&lt;/code&gt;. With the guest loopback rule restored it logged &lt;code&gt;PLAIN login refused: user 'guest' can only connect via localhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F04-rabbitmq-docker-access-refused-invalid-credentials.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F04-rabbitmq-docker-access-refused-invalid-credentials.webp" alt="Terminal capture: wrong guest password returns ACCESS_REFUSED PLAIN on the client and PLAIN login refused invalid credentials in the RabbitMQ broker log" width="798" height="127"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Guest loopback on bare metal vs the Docker image
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://www.rabbitmq.com/docs/access-control#loopback-users" rel="noopener noreferrer"&gt;RabbitMQ access control docs&lt;/a&gt; state the product default: "By default, the &lt;code&gt;guest&lt;/code&gt; user is prohibited from connecting from remote hosts. It can only connect over a loopback interface (&lt;code&gt;localhost&lt;/code&gt;)."&lt;/p&gt;

&lt;p&gt;The official Docker image changes that for Compose peers. In &lt;code&gt;rabbitmq:4.4.0-management-alpine&lt;/code&gt;, &lt;code&gt;/etc/rabbitmq/conf.d/10-defaults.conf&lt;/code&gt; contains this setting (the image file comments it with &lt;code&gt;##&lt;/code&gt;, shown here with &lt;code&gt;#&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;# allow access to the guest user from anywhere on the network
loopback_users.guest = false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a stock image often accepts &lt;code&gt;guest&lt;/code&gt; from another container on the Compose network. That surprises people who paste bare-metal advice into a Docker setup, and the reverse also happens: if you restore the product default, guest from a peer fails again.&lt;/p&gt;

&lt;p&gt;The lab copied &lt;code&gt;loopback_users.guest = true&lt;/code&gt; into &lt;code&gt;conf.d/20-loopback.conf&lt;/code&gt; and rebuilt a thin image on top of &lt;code&gt;rabbitmq:4.4.0-management-alpine&lt;/code&gt;. From a peer container, guest then failed with ACCESS_REFUSED. The broker said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PLAIN login refused: user 'guest' can only connect via localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same guest credentials from &lt;code&gt;127.0.0.1&lt;/code&gt; inside the RabbitMQ network namespace opened AMQP successfully.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F03-rabbitmq-docker-access-refused-guest-loopback.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F03-rabbitmq-docker-access-refused-guest-loopback.webp" alt="Terminal capture: guest from the rabbitmq service name gets ACCESS_REFUSED with broker log user guest can only connect via localhost, then the same guest credentials succeed on 127.0.0.1 inside the broker container" width="800" height="241"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do not "fix" this by setting &lt;code&gt;loopback_users = none&lt;/code&gt; in production. Create a non-guest user instead. The docs call remote guest access a danger zone for a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix DEFAULT_USER that never changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Seed a user on first boot
&lt;/h3&gt;

&lt;p&gt;On an empty data directory, the image reads &lt;code&gt;RABBITMQ_DEFAULT_USER&lt;/code&gt; and &lt;code&gt;RABBITMQ_DEFAULT_PASS&lt;/code&gt; and creates that administrator. The lab used:&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;rabbitmq&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;rabbitmq:4.4.0-management-alpine&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rabbitmq&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;RABBITMQ_DEFAULT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;labuser&lt;/span&gt;
      &lt;span class="na"&gt;RABBITMQ_DEFAULT_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lab-Pass-4821&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5672:5672"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:15672:15672"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rabbitmq-diagnostics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;12&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;15s&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;rmq-data:/var/lib/rabbitmq&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;rmq-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the container became healthy, &lt;code&gt;rabbitmqctl list_users&lt;/code&gt; showed only &lt;code&gt;labuser&lt;/code&gt; with the administrator tag. The Pika client with those credentials published and consumed a durable queue message. &lt;code&gt;guest&lt;/code&gt;/&lt;code&gt;guest&lt;/code&gt; then returned ACCESS_REFUSED with &lt;code&gt;invalid credentials&lt;/code&gt;. The management HTTP API on &lt;code&gt;127.0.0.1:15672&lt;/code&gt; answered 200 for &lt;code&gt;labuser&lt;/code&gt; and reported &lt;code&gt;rabbitmq_version&lt;/code&gt; &lt;code&gt;4.4.0&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A named volume keeps the first password
&lt;/h3&gt;

&lt;p&gt;The image stores users in &lt;code&gt;/var/lib/rabbitmq&lt;/code&gt;. If you change &lt;code&gt;RABBITMQ_DEFAULT_PASS&lt;/code&gt; in Compose and recreate the container with the same named volume, the env vars do not rewrite the existing user. That is the core of &lt;a href="https://github.com/docker-library/rabbitmq/issues/150" rel="noopener noreferrer"&gt;docker-library/rabbitmq#150&lt;/a&gt; (29 comments): people edit the password in &lt;code&gt;.env&lt;/code&gt;, restart, and still get ACCESS_REFUSED.&lt;/p&gt;

&lt;p&gt;In the lab, the volume kept &lt;code&gt;Lab-Pass-4821&lt;/code&gt;. After switching the env to &lt;code&gt;New-Pass-9999&lt;/code&gt;, the new password failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL ProbableAuthenticationError: ConnectionClosedByBroker: (403) 'ACCESS_REFUSED - Login was refused using authentication mechanism PLAIN. For details see the broker logfile.'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker logged &lt;code&gt;PLAIN login refused: user 'labuser' - invalid credentials&lt;/code&gt;. The old password still opened AMQP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F05-rabbitmq-docker-default-user-stale-volume.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Frabbitmq-docker-access-refused%2F05-rabbitmq-docker-default-user-stale-volume.webp" alt="Terminal capture: labuser with RABBITMQ_DEFAULT_USER works on first boot, then after changing RABBITMQ_DEFAULT_PASS with the same volume the new password gets ACCESS_REFUSED while the original password still connects" width="799" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To rotate credentials on purpose, change the password with &lt;code&gt;rabbitmqctl change_password&lt;/code&gt;, or remove the volume and accept a wipe of queues and users, then boot once with the new env. The &lt;a href="https://deploylark.comhttps://deploylark.com/posts/postgres-docker-password-authentication-failed/" rel="noopener noreferrer"&gt;PostgreSQL password guide&lt;/a&gt; and the &lt;a href="https://deploylark.comhttps://deploylark.com/posts/mongodb-docker-authentication-failed/" rel="noopener noreferrer"&gt;MongoDB auth guide&lt;/a&gt; hit the same first-boot-vs-volume pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Compose file that works
&lt;/h2&gt;

&lt;p&gt;Put the service name, a non-guest user, a healthcheck, and a volume 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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rabbitmq&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;rabbitmq:4.4.0-management-alpine&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rabbitmq&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;RABBITMQ_DEFAULT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${RABBITMQ_DEFAULT_USER:?set RABBITMQ_DEFAULT_USER in .env}&lt;/span&gt;
      &lt;span class="na"&gt;RABBITMQ_DEFAULT_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${RABBITMQ_DEFAULT_PASS:?set RABBITMQ_DEFAULT_PASS in .env}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5672:5672"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:15672:15672"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rabbitmq-diagnostics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;12&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;15s&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;rmq-data:/var/lib/rabbitmq&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app:tag&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;AMQP_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672/&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;rabbitmq&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rmq-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RABBITMQ_DEFAULT_USER=labuser
RABBITMQ_DEFAULT_PASS=replace-me-with-a-long-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point every client at host &lt;code&gt;rabbitmq&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt;. Drop the &lt;code&gt;ports&lt;/code&gt; block if nothing on the host needs AMQP or the management UI. Keep &lt;code&gt;127.0.0.1&lt;/code&gt; when you do publish, the same habit as in the &lt;a href="https://deploylark.comhttps://deploylark.com/posts/portainer-ce-install-stacks/" rel="noopener noreferrer"&gt;Portainer guide&lt;/a&gt; when you bind admin ports to the host loopback only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker compose up -d
$ docker compose ps
# rabbitmq health: healthy
$ docker compose exec rabbitmq rabbitmqctl list_users
Listing users ...
user    tags
labuser [administrator]
$ curl -sS -u labuser:replace-me-with-a-long-secret http://127.0.0.1:15672/api/overview | head -c 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the app still fails, read &lt;code&gt;docker compose logs rabbitmq&lt;/code&gt; for the &lt;code&gt;PLAIN login refused&lt;/code&gt; line before you change more config.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Connection refused
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does RabbitMQ refuse connections between Docker Compose containers?
&lt;/h4&gt;

&lt;p&gt;Usually the app uses &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;127.0.0.1&lt;/code&gt;, which is its own container. Use the Compose service name, such as &lt;code&gt;rabbitmq&lt;/code&gt;. If the host is already correct, the broker may not be listening yet. Add a &lt;code&gt;rabbitmq-diagnostics -q ping&lt;/code&gt; healthcheck and &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I connect to RabbitMQ in Docker from the host machine?
&lt;/h4&gt;

&lt;p&gt;Publish the port, for example &lt;code&gt;ports: ["127.0.0.1:5672:5672"]&lt;/code&gt;, and connect to &lt;code&gt;127.0.0.1:5672&lt;/code&gt; with a user that exists in the broker. Without &lt;code&gt;ports&lt;/code&gt;, the host gets Connection refused. Other containers on the Compose network do not need a published port.&lt;/p&gt;

&lt;h3&gt;
  
  
  ACCESS_REFUSED and guest
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why do I get ACCESS_REFUSED with guest and PLAIN from another container?
&lt;/h4&gt;

&lt;p&gt;Check the broker log. If it says &lt;code&gt;user 'guest' can only connect via localhost&lt;/code&gt;, the loopback rule is on. Create a non-guest user with &lt;code&gt;RABBITMQ_DEFAULT_USER&lt;/code&gt; and &lt;code&gt;RABBITMQ_DEFAULT_PASS&lt;/code&gt; instead of reopening guest. If the log says &lt;code&gt;invalid credentials&lt;/code&gt;, the password or username is wrong for the data in the volume.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does the official RabbitMQ Docker image allow guest from other containers?
&lt;/h4&gt;

&lt;p&gt;Yes, by default. &lt;code&gt;rabbitmq:4.4.0-management-alpine&lt;/code&gt; ships &lt;code&gt;loopback_users.guest = false&lt;/code&gt; in &lt;code&gt;/etc/rabbitmq/conf.d/10-defaults.conf&lt;/code&gt; so Compose peers can use guest. That differs from bare-metal RabbitMQ, where guest is localhost-only. Still create a dedicated user for anything beyond a throwaway lab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Users and volumes
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does RABBITMQ_DEFAULT_PASS not change my password after a restart?
&lt;/h4&gt;

&lt;p&gt;Those variables seed the default user only when the data directory is empty. A named volume keeps the first user and password. Change the password with &lt;code&gt;rabbitmqctl change_password&lt;/code&gt;, or remove the volume and boot once with the new env if you accept wiping broker data.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I create a RabbitMQ user for Docker Compose the right way?
&lt;/h4&gt;

&lt;p&gt;Set &lt;code&gt;RABBITMQ_DEFAULT_USER&lt;/code&gt; and &lt;code&gt;RABBITMQ_DEFAULT_PASS&lt;/code&gt; before the first start with an empty volume, or run &lt;code&gt;rabbitmqctl add_user&lt;/code&gt;, &lt;code&gt;set_user_tags&lt;/code&gt;, and &lt;code&gt;set_permissions&lt;/code&gt; against a running node. Point clients at that user and the service name &lt;code&gt;rabbitmq&lt;/code&gt; on port 5672.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>rabbitmq</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>S3 NoSuchBucket in Docker: Fix Bucket Not Found Errors</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Fri, 06 Nov 2026 03:23:45 +0000</pubDate>
      <link>https://dev.to/deploylark/s3-nosuchbucket-in-docker-fix-bucket-not-found-errors-582</link>
      <guid>https://dev.to/deploylark/s3-nosuchbucket-in-docker-fix-bucket-not-found-errors-582</guid>
      <description>&lt;p&gt;Your app talks to self-hosted S3 storage in Docker, and the upload fails with &lt;code&gt;NoSuchBucket&lt;/code&gt;. The message says &lt;code&gt;Bucket not found&lt;/code&gt; or &lt;code&gt;The specified bucket does not exist&lt;/code&gt;. But the bucket is right there in the admin UI. Or the same call works from the AWS CLI and fails from your Node app.&lt;/p&gt;

&lt;p&gt;This guide reproduces those failures in Docker Compose with Garage v2.5.0, a maintained open-source S3 server, and fixes each one. Every Garage and RustFS error below comes from that lab run on November 2, 2026. In several cases the bucket existed the whole time. The server read a different bucket name than the one you meant, because of a typo, the URL style, the endpoint or the access key.&lt;/p&gt;

&lt;p&gt;The questions get steady traffic. On Stack Overflow, &lt;a href="https://stackoverflow.com/questions/21335580/code-nosuchbucket" rel="noopener noreferrer"&gt;Code: NoSuchBucket&lt;/a&gt; had 78,316 views on November 2, 2026, and &lt;a href="https://stackoverflow.com/questions/56627446/docker-compose-how-to-use-minio-in-and-outside-of-the-docker-network" rel="noopener noreferrer"&gt;docker-compose: how to use minio in- and outside of the docker network&lt;/a&gt; had 57,838.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read the bucket name inside the error. Garage prints it, as in &lt;code&gt;Bucket not found: cat.txt&lt;/code&gt;. If that isn't your bucket, the request was built wrong.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;forcePathStyle: true&lt;/code&gt; (or the path-style option of your SDK) for self-hosted endpoints. The AWS SDK for JavaScript v3 puts the bucket in the hostname by default.&lt;/li&gt;
&lt;li&gt;Set the region to the one the server expects. Garage uses &lt;code&gt;garage&lt;/code&gt; unless you change &lt;code&gt;s3_region&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Point the endpoint at the S3 API port (3900 for Garage) with no bucket in the URL.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AccessDenied&lt;/code&gt; means the bucket exists but your key can't use it. &lt;code&gt;NoSuchBucket&lt;/code&gt; means the server found no bucket under that name for that key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix NoSuchBucket: find the bucket name the server saw
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Read the name in the error
&lt;/h3&gt;

&lt;p&gt;The lab created one bucket, &lt;code&gt;photos&lt;/code&gt;, and one access key with read and write rights on it. Then it ran &lt;code&gt;aws&lt;/code&gt; 2.39.3 in a container on the same Compose network with &lt;code&gt;AWS_ENDPOINT_URL=http://garage:3900&lt;/code&gt; and &lt;code&gt;AWS_DEFAULT_REGION=garage&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F02-s3-nosuchbucket-bucket-not-found-garage.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F02-s3-nosuchbucket-bucket-not-found-garage.webp" alt="Terminal capture: garage bucket list shows photos and invoices, aws s3 ls s3://Photos and a copy to s3://pictures fail with NoSuchBucket Bucket not found, head-bucket returns 404 Not Found, the raw XML error body names the bucket, and aws s3 ls s3://photos lists cat.txt" width="800" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bucket names are case sensitive. &lt;code&gt;Photos&lt;/code&gt; is not &lt;code&gt;photos&lt;/code&gt;, so Garage answered &lt;code&gt;NoSuchBucket&lt;/code&gt; with &lt;code&gt;Bucket not found: Photos&lt;/code&gt;. The raw XML body carries the same name in &lt;code&gt;&amp;lt;Message&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;Resource&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Error&amp;gt;&amp;lt;Code&amp;gt;&lt;/span&gt;NoSuchBucket&lt;span class="nt"&gt;&amp;lt;/Code&amp;gt;&amp;lt;Message&amp;gt;&lt;/span&gt;Bucket not found: pictures&lt;span class="nt"&gt;&amp;lt;/Message&amp;gt;&amp;lt;Resource&amp;gt;&lt;/span&gt;/pictures&lt;span class="nt"&gt;&amp;lt;/Resource&amp;gt;&amp;lt;Region&amp;gt;&lt;/span&gt;garage&lt;span class="nt"&gt;&amp;lt;/Region&amp;gt;&amp;lt;/Error&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Garage log shows the same thing with the method and path, for example &lt;code&gt;error 404 Not Found, Bucket not found: pictures ... HEAD /pictures&lt;/code&gt;. Run &lt;code&gt;docker compose logs garage&lt;/code&gt; and compare the path with the bucket you meant.&lt;/p&gt;

&lt;p&gt;Other servers word it differently. RustFS 1.0.4 answered a missing bucket with &lt;code&gt;NoSuchBucket: The specified bucket does not exist&lt;/code&gt;, the same text MinIO used, and rejected &lt;code&gt;Photos&lt;/code&gt; with &lt;code&gt;InvalidBucketName: The specified bucket is not valid.&lt;/code&gt; Supabase Storage wraps the error in JSON, &lt;code&gt;{"statusCode":"404","error":"Bucket not found","message":"Bucket not found","code":"NoSuchBucket"}&lt;/code&gt;, which the &lt;a href="https://deploylark.com/posts/supabase-self-host-docker/" rel="noopener noreferrer"&gt;Supabase self-hosting guide&lt;/a&gt; ran into before the bucket was created.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the bucket with head-bucket
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;aws s3api head-bucket --bucket pictures&lt;/code&gt; returned &lt;code&gt;An error occurred (404) when calling the HeadBucket operation: Not Found&lt;/code&gt; and exit code 254. A HEAD response has no body, so you get a bare 404 instead of &lt;code&gt;NoSuchBucket&lt;/code&gt;. That makes it a quick existence check in a script. On Garage, &lt;code&gt;docker compose exec garage /garage bucket list&lt;/code&gt; shows every bucket name, called a global alias, and every per-key name, called a local alias. More on those below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix Bucket not found caused by virtual-hosted URLs
&lt;/h2&gt;

&lt;p&gt;Start with how the request is built. S3 clients can address a bucket in two ways, and the &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html" rel="noopener noreferrer"&gt;AWS docs on virtual hosting&lt;/a&gt; describe both. Path-style puts the bucket in the path. Virtual-hosted style puts it in the hostname.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F01-s3-path-style-vs-virtual-hosted-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F01-s3-path-style-vs-virtual-hosted-diagram.webp" alt="Diagram: the same GetObject call for bucket photos and key cat.txt. With forcePathStyle true the request goes to http://garage:3900/photos/cat.txt and works. With the SDK default it goes to http://photos.garage:3900/cat.txt and fails with getaddrinfo ENOTFOUND photos.garage, or with NoSuchBucket Bucket not found cat.txt when that name resolves to Garage" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Set forcePathStyle for self-hosted endpoints
&lt;/h3&gt;

&lt;p&gt;The lab used a small Node.js script with &lt;code&gt;@aws-sdk/client-s3&lt;/code&gt; 3.1161.0 on &lt;code&gt;node:24.22.2-alpine&lt;/code&gt;. It set &lt;code&gt;endpoint: "http://garage:3900"&lt;/code&gt; and &lt;code&gt;region: "garage"&lt;/code&gt; and nothing else. The SDK built &lt;code&gt;http://photos.garage:3900/cat.txt&lt;/code&gt;. No DNS record exists for &lt;code&gt;photos.garage&lt;/code&gt;, so the call failed before it reached Garage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: getaddrinfo ENOTFOUND photos.garage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The box this ran on resolves unknown names to a proxy, so the lab pointed the client at a &lt;a href="https://coredns.io/" rel="noopener noreferrer"&gt;CoreDNS&lt;/a&gt; 1.15.0 container that answers "no such name" for everything, the way a normal network does. With &lt;code&gt;forcePathStyle: true&lt;/code&gt;, the same script fetched &lt;code&gt;http://garage:3900/photos/cat.txt&lt;/code&gt; and printed &lt;code&gt;OK hello garage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The worse case is when &lt;code&gt;photos.garage&lt;/code&gt; does resolve, for example through a wildcard DNS record or a proxy that sends every subdomain to Garage. The lab simulated that with a Compose network alias. Garage did not recognize the hostname, so it treated the request as path-style and read the object key as the bucket name:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F03-s3-forcepathstyle-enotfound-nosuchbucket.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F03-s3-forcepathstyle-enotfound-nosuchbucket.webp" alt="Terminal capture: the AWS SDK for JavaScript v3 with its default URL style fails with getaddrinfo ENOTFOUND photos.garage, then with NoSuchBucket Bucket not found cat.txt once the name resolves, the Garage log shows GET /cat.txt, and forcePathStyle true or a matching root_domain endpoint works" width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NoSuchBucket: Bucket not found: cat.txt&lt;/code&gt; is the giveaway. &lt;code&gt;cat.txt&lt;/code&gt; is a file, not a bucket. An upload failed in a stranger way. Garage read &lt;code&gt;PUT /from-node.txt&lt;/code&gt; as a request to create a bucket called &lt;code&gt;from-node.txt&lt;/code&gt; and returned &lt;code&gt;InvalidRequest: Bad request: Invalid create bucket XML query&lt;/code&gt;. Here is the client config that fixed both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://garage:3900&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// http://garage:3900/&amp;lt;bucket&amp;gt;/&amp;lt;key&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other SDKs have their own path-style option, so check their docs. The AWS CLI 2.39.3 in the lab used path-style on its own with a custom endpoint. Its debug log showed &lt;code&gt;http://garage:3900/photos?list-type=2...&lt;/code&gt;. That is why the CLI worked while the Node app failed with the same keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Or configure root_domain and wildcard DNS
&lt;/h3&gt;

&lt;p&gt;If an app can't do path-style, give Garage a domain for virtual-hosted requests. The lab config had &lt;code&gt;root_domain = ".s3.garage.localhost"&lt;/code&gt; under &lt;code&gt;[s3_api]&lt;/code&gt;. With the endpoint set to &lt;code&gt;http://s3.garage.localhost:3900&lt;/code&gt; and a network alias for &lt;code&gt;photos.s3.garage.localhost&lt;/code&gt;, the script worked without &lt;code&gt;forcePathStyle&lt;/code&gt;. In production that alias is a wildcard DNS record such as &lt;code&gt;*.s3.example.com&lt;/code&gt;, plus a wildcard TLS certificate if you serve HTTPS. The &lt;a href="https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/" rel="noopener noreferrer"&gt;Garage configuration reference&lt;/a&gt; notes: "Note path-style requests are always enabled, whether or not vhost-style is configured." So path-style keeps working after you add the domain. Garage picks the bucket from the &lt;code&gt;Host&lt;/code&gt; header, so a reverse proxy in front has to pass that header through unchanged. The &lt;a href="https://deploylark.com/posts/traefik-docker-labels-404-502/" rel="noopener noreferrer"&gt;Traefik labels guide&lt;/a&gt; covers routing by host name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the region and the endpoint URL
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Match the region to s3_region
&lt;/h3&gt;

&lt;p&gt;Garage only accepts requests signed for its own region. The Garage configuration reference says: "Garage will accept S3 API calls that are targeted to the S3 region defined here. API calls targeted to other regions will fail with a AuthorizationHeaderMalformed error message that redirects the client to the correct region."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F04-s3-wrong-region-endpoint-authorizationheadermalformed.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F04-s3-wrong-region-endpoint-authorizationheadermalformed.webp" alt="Terminal capture: the SDK with region us-east-1 fails with AuthorizationHeaderMalformed unexpected scope, the AWS CLI debug log shows a us-east-1 request followed by a retry signed for garage, port 3902 returns 404, port 3903 returns 400, and an endpoint that includes the bucket makes Garage look up /photos/photos/cat.txt" width="800" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;region: "us-east-1"&lt;/code&gt;, the Node SDK failed with &lt;code&gt;AuthorizationHeaderMalformed: Authorization header malformed, unexpected scope: '20261102/us-east-1/s3/aws4_request', expected: '20261102/garage/s3/aws4_request'&lt;/code&gt;. The AWS CLI hid the same mistake. With &lt;code&gt;--region us-east-1&lt;/code&gt; it listed the bucket fine, and its debug log showed why: one request signed for &lt;code&gt;us-east-1&lt;/code&gt;, the &lt;code&gt;AuthorizationHeaderMalformed&lt;/code&gt; answer, then a retry signed for &lt;code&gt;garage&lt;/code&gt;. So set the region in your app to the &lt;code&gt;s3_region&lt;/code&gt; value from &lt;code&gt;garage.toml&lt;/code&gt;, which is &lt;code&gt;garage&lt;/code&gt; in the Garage quick start. RustFS accepted &lt;code&gt;us-east-1&lt;/code&gt; in the lab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Point the endpoint at the S3 port, without the bucket
&lt;/h3&gt;

&lt;p&gt;Garage listens on several ports. Only &lt;code&gt;3900&lt;/code&gt; speaks the S3 API. The lab tried the others:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http://garage:3902&lt;/code&gt; (the static website port) returned &lt;code&gt;(404) ... Not Found&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http://garage:3903&lt;/code&gt; (the admin API) returned &lt;code&gt;(400) ... Bad Request&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http://garage:3900/photos&lt;/code&gt;, with the bucket inside the endpoint, made the client add the bucket a second time. Garage looked up &lt;code&gt;/photos/photos/cat.txt&lt;/code&gt;, and &lt;code&gt;aws s3 ls&lt;/code&gt; failed with &lt;code&gt;NoSuchKey: Key not found&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A wrong secret key returned &lt;code&gt;AccessDenied: Forbidden: Invalid signature&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoint is the server address only, such as &lt;code&gt;http://garage:3900&lt;/code&gt;. The bucket goes in the bucket parameter. From another container, use the Compose service name. From the host, use the published port. In the lab, &lt;code&gt;--endpoint-url http://127.0.0.1:3900&lt;/code&gt; listed the bucket from the host. &lt;code&gt;localhost&lt;/code&gt; inside a container is that container, the same trap the &lt;a href="https://deploylark.com/posts/redis-docker-connection-refused-noauth-misconf/" rel="noopener noreferrer"&gt;Redis in Docker guide&lt;/a&gt; shows. And if you put a TLS proxy in front, match &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt; with what the proxy serves. The &lt;a href="https://deploylark.com/posts/docker-registry-http-response-https-client/" rel="noopener noreferrer"&gt;private registry guide&lt;/a&gt; shows what a scheme mismatch looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  AccessDenied or NoSuchBucket: check the access key
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Grant the key access to the bucket
&lt;/h3&gt;

&lt;p&gt;In Garage, a key sees nothing until you run &lt;code&gt;garage bucket allow&lt;/code&gt; for it. The lab created &lt;code&gt;report-key&lt;/code&gt; with read access to &lt;code&gt;photos&lt;/code&gt; only:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F05-garage-accessdenied-vs-nosuchbucket-keys.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fs3-nosuchbucket-bucket-not-found-docker%2F05-garage-accessdenied-vs-nosuchbucket-keys.webp" alt="Terminal capture: report-key lists only photos, gets AccessDenied on invoices, NoSuchBucket on a missing bucket and on another key's local alias media, AccessDenied when creating a bucket, and RustFS returns The specified bucket does not exist and InvalidBucketName for the same mistakes" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The difference matters. &lt;code&gt;invoices&lt;/code&gt; exists, so the key got &lt;code&gt;AccessDenied: Forbidden: Operation is not allowed for this key.&lt;/code&gt; A bucket that didn't exist got &lt;code&gt;NoSuchBucket&lt;/code&gt;. &lt;code&gt;aws s3 ls&lt;/code&gt; only listed &lt;code&gt;photos&lt;/code&gt;, the one bucket the key could use. Creating a bucket failed with &lt;code&gt;AccessDenied: Forbidden: Access key GK8a04... is not allowed to create buckets&lt;/code&gt;, because the key had no create right. Grant access with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;garage /garage bucket allow &lt;span class="nt"&gt;--read&lt;/span&gt; &lt;span class="nt"&gt;--write&lt;/span&gt; photos &lt;span class="nt"&gt;--key&lt;/span&gt; report-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Garage quick start also adds &lt;code&gt;--owner&lt;/code&gt;. Run &lt;code&gt;garage key info &amp;lt;key&amp;gt;&lt;/code&gt; to see which buckets a key can use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local aliases are per key
&lt;/h3&gt;

&lt;p&gt;Garage lets a key give a bucket its own private name, a local alias. The lab ran &lt;code&gt;garage bucket alias --local app-key photos media&lt;/code&gt;. For &lt;code&gt;app-key&lt;/code&gt;, &lt;code&gt;s3://media&lt;/code&gt; listed the files in &lt;code&gt;photos&lt;/code&gt;. For &lt;code&gt;report-key&lt;/code&gt;, which had read access to the same bucket, &lt;code&gt;s3://media&lt;/code&gt; returned &lt;code&gt;NoSuchBucket: Bucket not found: media&lt;/code&gt;. If two services share a bucket, use the global alias, or create the same local alias for each key.&lt;/p&gt;

&lt;h2&gt;
  
  
  MinIO images are gone: a Garage Compose file that works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happened to minio/minio
&lt;/h3&gt;

&lt;p&gt;Many older tutorials pin &lt;code&gt;minio/minio&lt;/code&gt;. On November 2, 2026, the Docker Hub API answered &lt;code&gt;object not found&lt;/code&gt; for that repository. &lt;code&gt;docker pull minio/minio:RELEASE.2025-09-07T16-13-09Z&lt;/code&gt; failed with &lt;code&gt;pull access denied for minio/minio, repository does not exist&lt;/code&gt;, and the same tag on &lt;code&gt;quay.io/minio/minio&lt;/code&gt; returned &lt;code&gt;unauthorized: access to the requested resource is not authorized&lt;/code&gt;. The &lt;a href="https://github.com/minio/minio" rel="noopener noreferrer"&gt;MinIO GitHub repository&lt;/a&gt; was archived on April 25, 2026, and its README says "The MinIO community edition is now distributed as source code only." &lt;a href="https://www.stablebuild.com/blog/minio-images-disappeared-from-docker-hub" rel="noopener noreferrer"&gt;StableBuild's write-up&lt;/a&gt; dates the Docker Hub removal to around September 12, 2026.&lt;/p&gt;

&lt;p&gt;So a fresh &lt;code&gt;docker compose up&lt;/code&gt; with a MinIO image now fails before any bucket error can happen. The lab moved to Garage. RustFS 1.0.4 also ran fine, and its error messages are closer to MinIO's. The fixes above apply to both, since they come from the S3 protocol and the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  compose.yaml, garage.toml and the app
&lt;/h3&gt;

&lt;p&gt;This setup ran verbatim in the lab. It uses the &lt;code&gt;--single-node&lt;/code&gt; and &lt;code&gt;--default-bucket&lt;/code&gt; flags from the &lt;a href="https://garagehq.deuxfleurs.fr/documentation/quick-start/" rel="noopener noreferrer"&gt;Garage quick start&lt;/a&gt;, which create the layout, one access key and one bucket from environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="n"&gt;metadata_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/var/lib/garage/meta"&lt;/span&gt;
&lt;span class="n"&gt;data_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/var/lib/garage/data"&lt;/span&gt;
&lt;span class="n"&gt;db_engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"sqlite"&lt;/span&gt;
&lt;span class="n"&gt;replication_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="n"&gt;rpc_bind_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"[::]:3901"&lt;/span&gt;
&lt;span class="n"&gt;rpc_public_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"127.0.0.1:3901"&lt;/span&gt;

&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s3_api&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;s3_region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"garage"&lt;/span&gt;
&lt;span class="n"&gt;api_bind_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"[::]:3900"&lt;/span&gt;

&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;api_bind_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"[::]:3903"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;garage&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;dxflrs/garage:v2.5.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/garage"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--single-node"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-bucket"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GARAGE_RPC_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GARAGE_RPC_SECRET}&lt;/span&gt;
      &lt;span class="na"&gt;GARAGE_ADMIN_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GARAGE_ADMIN_TOKEN}&lt;/span&gt;
      &lt;span class="na"&gt;GARAGE_DEFAULT_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_ACCESS_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;GARAGE_DEFAULT_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_SECRET_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;GARAGE_DEFAULT_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_BUCKET}&lt;/span&gt;
    &lt;span class="na"&gt;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;./garage.toml:/etc/garage.toml:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;garage-meta:/var/lib/garage/meta&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;garage-data:/var/lib/garage/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3900:3900"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/garage"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status"&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:24.22.2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.mjs"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;garage&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://garage:3900&lt;/span&gt;
      &lt;span class="na"&gt;S3_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage&lt;/span&gt;
      &lt;span class="na"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_BUCKET}&lt;/span&gt;
      &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_ACCESS_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${S3_SECRET_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;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;./app:/app:ro&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;garage-meta&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;garage-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fill &lt;code&gt;.env&lt;/code&gt; with your own random values. The access key ID starts with &lt;code&gt;GK&lt;/code&gt;, as in the quick start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
GARAGE_RPC_SECRET=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
GARAGE_ADMIN_TOKEN=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
S3_ACCESS_KEY=GK&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 16&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
S3_SECRET_KEY=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
S3_BUCKET=uploads
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;./app/app.mjs&lt;/code&gt; stands in for your real service. It needs &lt;code&gt;npm install @aws-sdk/client-s3@3.1161.0&lt;/code&gt; in &lt;code&gt;./app&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ListObjectsV2Command&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// http://garage:3900&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// must match s3_region in garage.toml&lt;/span&gt;
  &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// http://garage:3900/&amp;lt;bucket&amp;gt;/&amp;lt;key&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Contents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ListObjectsV2Command&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;Bucket&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check it
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;code&amp;gt;text&lt;br&gt;
$ docker compose up -d&lt;br&gt;
 Container s3final-garage-1  Healthy&lt;br&gt;
 Container s3final-app-1  Started&lt;br&gt;
$ docker compose ps -a --format '&amp;amp;#123;&amp;amp;#123;.Service&amp;amp;#125;&amp;amp;#125; &amp;amp;#123;&amp;amp;#123;.Status&amp;amp;#125;&amp;amp;#125;'&lt;br&gt;
app Exited (0) 1 second ago&lt;br&gt;
garage Up 12 seconds (healthy)&lt;br&gt;
$ docker compose logs --no-log-prefix app&lt;br&gt;
uploads: hello.txt&lt;br&gt;
$ docker compose restart garage&lt;br&gt;
$ docker compose run --rm app&lt;br&gt;
uploads: hello.txt&lt;br&gt;
&amp;lt;/code&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On the first start, Garage logged &lt;code&gt;Created initial layout for single-node configuration&lt;/code&gt;, &lt;code&gt;Creating default access key&lt;/code&gt; and &lt;code&gt;Creating default bucket uploads&lt;/code&gt;. The SDK reads the key from &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; on its own. Changing &lt;code&gt;S3_BUCKET&lt;/code&gt; later behaves differently from database images. With &lt;code&gt;S3_BUCKET=assets&lt;/code&gt;, Garage created a second bucket, &lt;code&gt;assets&lt;/code&gt;, on the next start and kept &lt;code&gt;uploads&lt;/code&gt; with its files. The default key kept its rights on &lt;code&gt;uploads&lt;/code&gt;, and the app still read it after switching back. Keep the image pinned, here &lt;code&gt;dxflrs/garage:v2.5.0&lt;/code&gt;, so a restart never pulls a new release onto your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  What does NoSuchBucket mean?
&lt;/h4&gt;

&lt;p&gt;The server found no bucket with the name in the request, for the key you used. Check the name in the error message first. If it shows a file name or a word you never used as a bucket, fix the URL style or the endpoint. If it shows your bucket, check the spelling, the case, and whether the bucket was created on this server.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does the AWS CLI work but my app gets NoSuchBucket or ENOTFOUND?
&lt;/h4&gt;

&lt;p&gt;The AWS CLI 2.39.3 used path-style URLs with a custom endpoint, and it retried a wrong region on its own. The AWS SDK for JavaScript v3 did neither. Set &lt;code&gt;forcePathStyle: true&lt;/code&gt; and the server's region in the app.&lt;/p&gt;

&lt;h4&gt;
  
  
  What does "The authorization header is malformed" mean with self-hosted S3?
&lt;/h4&gt;

&lt;p&gt;The client signed the request for a different region than the server uses. Garage answered &lt;code&gt;AuthorizationHeaderMalformed&lt;/code&gt; with the expected scope in the message. Set the client region to the server's &lt;code&gt;s3_region&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I check if a bucket exists?
&lt;/h4&gt;

&lt;p&gt;Run &lt;code&gt;aws s3api head-bucket --bucket &amp;lt;name&amp;gt;&lt;/code&gt;. In the lab, exit code 0 meant the bucket existed and the key could use it. A missing bucket returned &lt;code&gt;(404) ... Not Found&lt;/code&gt; and a bucket the key had no rights on returned &lt;code&gt;(403) ... Forbidden&lt;/code&gt;, both with exit code 254.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I connect to S3 storage inside and outside the Docker network?
&lt;/h4&gt;

&lt;p&gt;Inside the Compose network, use the service name, such as &lt;code&gt;http://garage:3900&lt;/code&gt;. From the host, publish the port and use &lt;code&gt;http://127.0.0.1:3900&lt;/code&gt;. Use path-style in both cases, so the bucket never ends up in a hostname.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I still pull the MinIO Docker image?
&lt;/h4&gt;

&lt;p&gt;Not from Docker Hub. On November 2, 2026, &lt;code&gt;minio/minio&lt;/code&gt; no longer existed there, and the Quay copy returned &lt;code&gt;unauthorized&lt;/code&gt;. The community edition is source only. Use a maintained server such as Garage or RustFS, or build MinIO from source at your own risk.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>aws</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Keycloak Behind a Reverse Proxy: Fix HTTPS Required</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Thu, 05 Nov 2026 23:28:48 +0000</pubDate>
      <link>https://dev.to/deploylark/keycloak-behind-a-reverse-proxy-fix-https-required-4fdg</link>
      <guid>https://dev.to/deploylark/keycloak-behind-a-reverse-proxy-fix-https-required-4fdg</guid>
      <description>&lt;p&gt;You open Keycloak in the browser and get a grey page that says &lt;code&gt;We are sorry... HTTPS required&lt;/code&gt;. Or you put it behind nginx, and the admin console stays blank, or the login page says &lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;. Your app then rejects every token with &lt;code&gt;Invalid token issuer&lt;/code&gt;. All of these come from the same question: what URL does Keycloak think it lives at?&lt;/p&gt;

&lt;p&gt;This guide reproduces each error with &lt;code&gt;quay.io/keycloak/keycloak:26.8.1&lt;/code&gt;, &lt;code&gt;nginx:1.31.8&lt;/code&gt; and &lt;code&gt;postgres:18.6&lt;/code&gt; in Docker Compose and fixes it. Every error message below comes from that lab run on November 4, 2026. The lab used the name &lt;code&gt;auth.example.test&lt;/code&gt; with a self-signed certificate in place of your real domain.&lt;/p&gt;

&lt;p&gt;These errors are common. On Stack Overflow, &lt;a href="https://stackoverflow.com/questions/45352880/keycloak-invalid-parameter-redirect-uri" rel="noopener noreferrer"&gt;keycloak Invalid parameter: redirect_uri&lt;/a&gt; had 505,136 views on November 4, 2026, and &lt;a href="https://stackoverflow.com/questions/30622599/https-required-while-logging-in-to-keycloak-as-admin" rel="noopener noreferrer"&gt;"HTTPS required" while logging in to Keycloak as admin&lt;/a&gt; had 136,879.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;HTTPS required&lt;/code&gt; means a client with a public IP address reached Keycloak over plain HTTP. Put a TLS proxy in front. Don't switch the realm to &lt;code&gt;Require SSL: None&lt;/code&gt; outside a lab.&lt;/li&gt;
&lt;li&gt;Behind a proxy that terminates TLS, set &lt;code&gt;KC_PROXY_HEADERS=xforwarded&lt;/code&gt; and make the proxy send &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, &lt;code&gt;X-Forwarded-Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Port&lt;/code&gt;. Without both, Keycloak builds &lt;code&gt;http://&lt;/code&gt; URLs, and you get a blank admin console and &lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;KC_HOSTNAME&lt;/code&gt; to the full public URL, such as &lt;code&gt;https://auth.example.test&lt;/code&gt;. It fixes the token issuer, so apps that talk to &lt;code&gt;http://keycloak:8080&lt;/code&gt; stop failing with &lt;code&gt;Invalid token issuer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For your own apps, the redirect URI must match the client's Valid redirect URIs exactly, including &lt;code&gt;https&lt;/code&gt;, the host and the port.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix Keycloak startup errors in production mode
&lt;/h2&gt;

&lt;p&gt;Most Compose examples on the internet use &lt;code&gt;start-dev&lt;/code&gt;. When you switch to &lt;code&gt;start&lt;/code&gt;, the production mode, the container exits before it serves a single page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key material not provided to setup HTTPS
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;command: start&lt;/code&gt; and only the admin variables set, the container stopped with exit code 2 and this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Key material not provided to setup HTTPS. Please configure your keys/certificates, or if HTTPS access is not needed see the `http-enabled` option. If you meant to start the server in development mode, see the `start-dev` command.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production mode expects its own TLS certificate. When a reverse proxy handles TLS, as in this guide, set &lt;code&gt;KC_HTTP_ENABLED: "true"&lt;/code&gt; so Keycloak listens on plain HTTP port 8080 inside the Docker network. The &lt;a href="https://www.keycloak.org/server/reverseproxy#_configure_the_reverse_proxy_headers" rel="noopener noreferrer"&gt;reverse proxy guide&lt;/a&gt; requires it for this setup: "If the TLS connection is terminated at the reverse proxy (edge termination), enabling HTTP through the &lt;code&gt;http-enabled&lt;/code&gt; setting is required."&lt;/p&gt;

&lt;h3&gt;
  
  
  hostname is not configured
&lt;/h3&gt;

&lt;p&gt;With HTTP enabled, the next start failed with exit code 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: hostname is not configured; either configure hostname, or set hostname-strict to false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quick fix you see in many answers is &lt;code&gt;KC_HOSTNAME_STRICT: "false"&lt;/code&gt;. It works, but it tells Keycloak to build every URL from the request, which causes the next two problems. Set &lt;code&gt;KC_HOSTNAME&lt;/code&gt; instead, as shown in the last section. Keycloak also logged a warning that "the server is running in an insecure context" while HTTPS was off, &lt;code&gt;proxy-headers&lt;/code&gt; was unset and &lt;code&gt;hostname-strict&lt;/code&gt; was false.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix "HTTPS required" in Keycloak
&lt;/h2&gt;

&lt;p&gt;This is the error on the grey Keycloak page. In the lab it came from the master realm login page, where the admin console sends you to sign in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F02-keycloak-https-required-error-page.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F02-keycloak-https-required-error-page.webp" alt="Keycloak error page in Chrome with the Keycloak logo and a white box that says We are sorry... HTTPS required, shown when the admin console is opened over plain HTTP from a public IP address" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Keycloak asks for HTTPS
&lt;/h3&gt;

&lt;p&gt;Every realm has a Require SSL setting. Its default, &lt;code&gt;external&lt;/code&gt;, allows plain HTTP only from private addresses. The &lt;a href="https://www.keycloak.org/docs/latest/server_admin/index.html#_ssl_modes" rel="noopener noreferrer"&gt;Keycloak server admin guide&lt;/a&gt; describes it: "If you try to access Keycloak without SSL from a non-private IP address, you will get an error."&lt;/p&gt;

&lt;p&gt;That explains why it works on your laptop and fails on a VPS. In the lab, Keycloak first ran on the default Compose network, &lt;code&gt;192.168.0.0/20&lt;/code&gt;, and the login page loaded over &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;. Then Keycloak moved to a Docker network in &lt;code&gt;203.0.113.0/24&lt;/code&gt;, a documentation range that counts as public, so it saw requests from &lt;code&gt;203.0.113.1&lt;/code&gt;. The same URL returned HTTP 400 with &lt;code&gt;HTTPS required&lt;/code&gt;, and the log named the reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type="LOGIN_ERROR", realmName="master", clientId="null", userId="null", ipAddress="203.0.113.1", error="ssl_required"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A VPS reached from the internet is in the same position. Your browser's address is public, so plain HTTP to port 8080 fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Serve Keycloak over HTTPS, not Require SSL: None
&lt;/h3&gt;

&lt;p&gt;The fix is HTTPS in front of Keycloak, which the rest of this guide sets up with nginx. If you already use &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; or &lt;a href="https://deploylark.com/posts/traefik-docker-labels-404-502/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt;, use that proxy and copy the header settings below.&lt;/p&gt;

&lt;p&gt;Many answers suggest switching the master realm to &lt;code&gt;Require SSL: None&lt;/code&gt;. It does work. From inside the container, where &lt;code&gt;localhost&lt;/code&gt; is a private address, &lt;code&gt;kcadm.sh&lt;/code&gt; changed the setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;keycloak /opt/keycloak/bin/kcadm.sh config credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server&lt;/span&gt; http://localhost:8080 &lt;span class="nt"&gt;--realm&lt;/span&gt; master &lt;span class="nt"&gt;--user&lt;/span&gt; admin &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;admin password&amp;gt;'&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;keycloak /opt/keycloak/bin/kcadm.sh update realms/master &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;sslRequired&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The login page loaded over plain HTTP from the public range right after. But the admin password and tokens then cross the internet unencrypted. The same guide says &lt;code&gt;None&lt;/code&gt; "applies only in development". The lab set it back with &lt;code&gt;-s sslRequired=EXTERNAL&lt;/code&gt;, and &lt;code&gt;HTTPS required&lt;/code&gt; returned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix a blank admin console and Invalid parameter: redirect_uri behind nginx
&lt;/h2&gt;

&lt;p&gt;Once nginx terminates TLS and forwards plain HTTP to Keycloak, a new set of errors appears. Keycloak no longer sees HTTPS, so it generates &lt;code&gt;http://&lt;/code&gt; links for a site that you reach over &lt;code&gt;https://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F01-keycloak-reverse-proxy-headers-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F01-keycloak-reverse-proxy-headers-diagram.webp" alt="Diagram: the browser connects over HTTPS port 443 to nginx, which ends TLS and adds the Host, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port and X-Forwarded-For headers, then forwards plain HTTP to Keycloak on port 8080 with KC_PROXY_HEADERS and KC_HOSTNAME set. An app container uses the http://keycloak:8080 backchannel, and port 9000 serves health checks without being proxied" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens without the forwarded headers
&lt;/h3&gt;

&lt;p&gt;The first nginx config in the lab only passed &lt;code&gt;proxy_set_header Host $host&lt;/code&gt;, and Keycloak had &lt;code&gt;KC_HOSTNAME_STRICT=false&lt;/code&gt; with no proxy settings. Opening &lt;code&gt;https://auth.example.test/admin/&lt;/code&gt; returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Location: http://auth.example.test/admin/master/console/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The OpenID discovery document listed &lt;code&gt;http://auth.example.test/realms/master&lt;/code&gt; as the issuer. In Chrome, the admin console stayed blank with a spinner, and the browser console showed why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mixed Content: The page at 'https://auth.example.test/admin/master/console/' was loaded over HTTPS, but requested an insecure resource 'http://auth.example.test/resources/master/admin/en'. This request has been blocked; the content must be served over HTTPS.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admin console's own sign-in failed too. A request with &lt;code&gt;redirect_uri=https://auth.example.test/admin/master/console/&lt;/code&gt; got HTTP 400 and &lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;, because Keycloak expected the &lt;code&gt;http://&lt;/code&gt; version.&lt;/p&gt;

&lt;p&gt;Adding the headers to nginx alone changed nothing. With &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and the others set, the redirect still went to &lt;code&gt;http://&lt;/code&gt;, because Keycloak ignores those headers until you enable them. The reverse also failed: with &lt;code&gt;KC_PROXY_HEADERS=xforwarded&lt;/code&gt; but the old nginx config, the redirect still pointed to &lt;code&gt;http://&lt;/code&gt;. You need both.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F03-keycloak-proxy-headers-mixed-content-redirect-uri.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F03-keycloak-proxy-headers-mixed-content-redirect-uri.webp" alt="Terminal capture: without KC_PROXY_HEADERS, Keycloak behind nginx redirects to http://auth.example.test, reports an http issuer, rejects the admin console redirect_uri with HTTP 400 and Chrome blocks Mixed Content. After adding the X-Forwarded headers and KC_PROXY_HEADERS=xforwarded, the redirect, issuer and login form all use https" width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Forward the headers and set KC_PROXY_HEADERS
&lt;/h3&gt;

&lt;p&gt;This nginx server block fixed it, together with &lt;code&gt;KC_PROXY_HEADERS: xforwarded&lt;/code&gt; on the Keycloak service:&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;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;auth.example.test&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;auth.example.test&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/nginx/certs/auth.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/certs/auth.key&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;/&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://keycloak:8080&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;$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-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-Host&lt;/span&gt;  &lt;span class="nv"&gt;$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-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="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;After the change, &lt;code&gt;/admin/&lt;/code&gt; redirected to &lt;code&gt;https://auth.example.test/admin/master/console/&lt;/code&gt;, the issuer started with &lt;code&gt;https://&lt;/code&gt;, and the admin console loaded with 0 console errors. &lt;code&gt;$server_port&lt;/code&gt; is the port nginx listens on inside its container. If you publish nginx on another host port, such as 8443, put that public port in &lt;code&gt;X-Forwarded-Port&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;Never publish Keycloak's port 8080 next to the proxy once headers are trusted. In the lab, a container on the same network sent &lt;code&gt;X-Forwarded-Host: evil.example&lt;/code&gt; straight to &lt;code&gt;keycloak:8080&lt;/code&gt;, and the discovery document answered with the issuer &lt;code&gt;https://evil.example/realms/master&lt;/code&gt;. The &lt;a href="https://www.keycloak.org/server/reverseproxy" rel="noopener noreferrer"&gt;reverse proxy guide&lt;/a&gt; warns that clients could "send forged forwarded headers directly to Keycloak" without network restrictions. The next section closes this hole for the host name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Invalid parameter: redirect_uri in your own apps
&lt;/h3&gt;

&lt;p&gt;The same error appears for your own clients when the redirect URI an app sends doesn't match the client's Valid redirect URIs. The lab client &lt;code&gt;app&lt;/code&gt; allowed &lt;code&gt;https://app.example.test/*&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;redirect_uri sent&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.test/callback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;login form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;login form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://app.example.test/callback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.test:8443/callback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://www.app.example.test/callback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scheme, the host and the port all have to match. The Keycloak log shows the URI that was rejected, which is the fastest way to see what your app sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type="LOGIN_ERROR", realmName="demo", clientId="app", ... error="invalid_redirect_uri", redirect_uri="https://www.app.example.test/callback"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;http://&lt;/code&gt; URI usually means your app sits behind its own proxy and builds its callback URL from the plain HTTP request. Fix the app's proxy settings or set its public URL, the same way you fixed Keycloak. The &lt;a href="https://deploylark.com/posts/home-assistant-reverse-proxy-400-bad-request/" rel="noopener noreferrer"&gt;Home Assistant&lt;/a&gt; and &lt;a href="https://deploylark.com/posts/nextcloud-docker-reverse-proxy-untrusted-domain/" rel="noopener noreferrer"&gt;Nextcloud&lt;/a&gt; guides show this for two common apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix "Invalid token issuer" with KC_HOSTNAME
&lt;/h2&gt;

&lt;p&gt;Even with the proxy headers fixed, &lt;code&gt;KC_HOSTNAME_STRICT=false&lt;/code&gt; makes Keycloak build every URL from the request, including the &lt;code&gt;iss&lt;/code&gt; claim in tokens. Your browser and your app's backend reach it at different addresses, so they disagree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the issuer changes
&lt;/h3&gt;

&lt;p&gt;In the lab, a user logged in through &lt;code&gt;https://auth.example.test&lt;/code&gt;, so the access token had &lt;code&gt;iss: https://auth.example.test/realms/demo&lt;/code&gt;. An app container then checked that token at &lt;code&gt;http://keycloak:8080&lt;/code&gt;, the address it uses inside the Docker network. The userinfo endpoint answered &lt;code&gt;401 Unauthorized&lt;/code&gt; with &lt;code&gt;error="invalid_token", error_description="Token verification failed"&lt;/code&gt;, and the Keycloak log gave the real reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reason="Invalid token issuer. Expected 'http://keycloak:8080/realms/demo'"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same token worked through the public URL. Keycloak expected the issuer of the address that the request came in on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F04-keycloak-invalid-token-issuer-docker.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F04-keycloak-invalid-token-issuer-docker.webp" alt="Terminal capture: a token issued through https://auth.example.test is rejected at http://keycloak:8080 with 401 and the log reason Invalid token issuer. Expected http://keycloak:8080/realms/demo. With KC_HOSTNAME and KC_HOSTNAME_BACKCHANNEL_DYNAMIC set, the internal discovery document keeps the https issuer, uses internal token and certs endpoints, userinfo returns 200 and a forged X-Forwarded-Host no longer changes the issuer" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Set the full public URL
&lt;/h3&gt;

&lt;p&gt;Set &lt;code&gt;KC_HOSTNAME: https://auth.example.test&lt;/code&gt;, the full URL with the scheme. With that in place, the discovery document at &lt;code&gt;http://keycloak:8080&lt;/code&gt; listed &lt;code&gt;https://auth.example.test/realms/demo&lt;/code&gt; as the issuer, and the internal userinfo call returned HTTP 200. The forged &lt;code&gt;X-Forwarded-Host: evil.example&lt;/code&gt; request from the previous section now got the correct issuer back. The &lt;a href="https://www.keycloak.org/server/hostname" rel="noopener noreferrer"&gt;hostname guide&lt;/a&gt; gives this as the reason the option exists: "By explicitly setting the hostname option, we avoid a situation where tokens could be issued by a fraudulent issuer."&lt;/p&gt;

&lt;p&gt;One catch remains. With only &lt;code&gt;KC_HOSTNAME&lt;/code&gt; set, the internal discovery document pointed the token and certs endpoints at &lt;code&gt;https://auth.example.test&lt;/code&gt; too, so a backend inside Docker has to reach the public name. Add &lt;code&gt;KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"&lt;/code&gt;. In the lab, the internal document then listed &lt;code&gt;http://keycloak:8080/realms/demo/protocol/openid-connect/token&lt;/code&gt; and &lt;code&gt;.../certs&lt;/code&gt;, kept the &lt;code&gt;https://&lt;/code&gt; issuer and the &lt;code&gt;https://&lt;/code&gt; login URL, and a token requested internally still carried &lt;code&gt;iss: https://auth.example.test/realms/demo&lt;/code&gt;. Browsers that use the public URL saw only &lt;code&gt;https://&lt;/code&gt; endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Keycloak Compose file with nginx that avoids these errors
&lt;/h2&gt;

&lt;p&gt;This file puts the fixes together. It ran verbatim in the lab with the nginx config from above saved as &lt;code&gt;nginx/keycloak.conf&lt;/code&gt;, and the certificate and key in &lt;code&gt;certs/&lt;/code&gt;. Use a real certificate for your domain, for example from your existing proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compose file
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt; with two random passwords:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'KC_DB_PASSWORD=%s\nKC_ADMIN_PASSWORD=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 16&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;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:18.6&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&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_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_PASSWORD:?set KC_DB_PASSWORD in .env}&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;pgdata:/var/lib/postgresql&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-U"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keycloak"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-d"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keycloak"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;

  &lt;span class="na"&gt;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.8.1&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;start&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;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:?set KC_DB_PASSWORD in .env}&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;https://auth.example.test&lt;/span&gt;
      &lt;span class="na"&gt;KC_HOSTNAME_BACKCHANNEL_DYNAMIC&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_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_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;KC_BOOTSTRAP_ADMIN_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD in .env}&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;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;nHost:&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;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;nConnection:&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;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;n'&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;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;UP&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="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;12&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;30s&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;nginx&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;nginx:1.31.8&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;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;./nginx/keycloak.conf:/etc/nginx/conf.d/default.conf:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certs:/etc/nginx/certs:ro&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;keycloak&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keycloak has no published port, so only nginx can reach it. The Keycloak image has no &lt;code&gt;curl&lt;/code&gt;, so the healthcheck talks to the management port 9000 with bash's &lt;code&gt;/dev/tcp&lt;/code&gt;. Port 9000 stays off the proxy, as the reverse proxy guide recommends. If Postgres refuses the password on a rerun, the &lt;a href="https://deploylark.com/posts/postgres-docker-password-authentication-failed/" rel="noopener noreferrer"&gt;Postgres password guide&lt;/a&gt; explains why the variables only apply on the first start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check that it works
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;docker compose up -d&lt;/code&gt;. Compose waited for each service in turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container kcfinal-postgres-1  Healthy
 Container kcfinal-keycloak-1  Starting
 Container kcfinal-keycloak-1  Started
 Container kcfinal-keycloak-1  Waiting
 Container kcfinal-keycloak-1  Healthy
 Container kcfinal-nginx-1  Starting
 Container kcfinal-nginx-1  Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keycloak logged &lt;code&gt;Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 2.962s&lt;/code&gt;, with no &lt;code&gt;insecure context&lt;/code&gt; warning and no errors. &lt;code&gt;http://auth.example.test/admin/&lt;/code&gt; returned a 301 to &lt;code&gt;https://&lt;/code&gt;, &lt;code&gt;https://auth.example.test/admin/&lt;/code&gt; redirected to the &lt;code&gt;https://&lt;/code&gt; console, and the admin login through Chrome worked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F05-keycloak-admin-console-behind-nginx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fkeycloak-reverse-proxy-https-required%2F05-keycloak-admin-console-behind-nginx.webp" alt="Keycloak 26.8.1 admin console loaded through nginx at https://auth.example.test, showing the master realm settings with Require SSL set to External requests and a banner about the temporary admin user" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The banner at the top is Keycloak's reminder that the bootstrap admin is temporary. Create a permanent admin account and delete the bootstrap one. For an internet-facing server, the reverse proxy guide also recommends that you don't expose &lt;code&gt;/admin/&lt;/code&gt; and &lt;code&gt;/realms/master/&lt;/code&gt; publicly, because "Exposed admin paths lead to an unnecessary attack vector." Your apps only need &lt;code&gt;/realms/&lt;/code&gt;, &lt;code&gt;/resources/&lt;/code&gt; and &lt;code&gt;/.well-known/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Errors on the login page
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I fix "HTTPS required" in Keycloak?
&lt;/h4&gt;

&lt;p&gt;Serve Keycloak over HTTPS, usually through a reverse proxy, and set &lt;code&gt;KC_PROXY_HEADERS&lt;/code&gt; and &lt;code&gt;KC_HOSTNAME&lt;/code&gt; so Keycloak knows the public URL is &lt;code&gt;https://&lt;/code&gt;. The error appears when a client with a public IP address uses plain HTTP while the realm's Require SSL is &lt;code&gt;external&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Should I set Require SSL to None in the master realm?
&lt;/h4&gt;

&lt;p&gt;Only in a throwaway lab. It removes the error, but logins and tokens then travel unencrypted. Keycloak's server admin guide limits &lt;code&gt;None&lt;/code&gt; to development. Fix HTTPS instead.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does the Keycloak admin console load forever behind a reverse proxy?
&lt;/h4&gt;

&lt;p&gt;Keycloak builds &lt;code&gt;http://&lt;/code&gt; URLs because it doesn't know the proxy uses HTTPS, and the browser blocks them as Mixed Content. Send &lt;code&gt;X-Forwarded-Proto&lt;/code&gt;, &lt;code&gt;X-Forwarded-Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Port&lt;/code&gt; from the proxy and set &lt;code&gt;KC_PROXY_HEADERS=xforwarded&lt;/code&gt;. The browser console lists the blocked &lt;code&gt;http://&lt;/code&gt; request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;h4&gt;
  
  
  What causes "Invalid parameter: redirect_uri" in Keycloak?
&lt;/h4&gt;

&lt;p&gt;The &lt;code&gt;redirect_uri&lt;/code&gt; sent by the app doesn't match any Valid redirect URI of the client. Check the scheme, host and port. The Keycloak log shows the rejected value in the &lt;code&gt;invalid_redirect_uri&lt;/code&gt; event. For the admin console itself, it means Keycloak computed an &lt;code&gt;http://&lt;/code&gt; URL behind the proxy.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I fix "Invalid token issuer" when Keycloak runs in Docker?
&lt;/h4&gt;

&lt;p&gt;Set &lt;code&gt;KC_HOSTNAME&lt;/code&gt; to the full public URL so the issuer is the same no matter which address an app uses. Add &lt;code&gt;KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true&lt;/code&gt; if your backend should call Keycloak at &lt;code&gt;http://keycloak:8080&lt;/code&gt; inside the Docker network.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can Keycloak run in production mode with HTTP only?
&lt;/h4&gt;

&lt;p&gt;Yes, behind a proxy that handles HTTPS. Set &lt;code&gt;KC_HTTP_ENABLED=true&lt;/code&gt;, &lt;code&gt;KC_PROXY_HEADERS&lt;/code&gt; and &lt;code&gt;KC_HOSTNAME&lt;/code&gt;, and don't publish port 8080. Without &lt;code&gt;http-enabled&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt; exits with &lt;code&gt;Key material not provided to setup HTTPS&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>keycloak</category>
      <category>nginx</category>
    </item>
    <item>
      <title>Docker Permission Denied on docker.sock: Fix It Without sudo</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:55:19 +0000</pubDate>
      <link>https://dev.to/deploylark/docker-permission-denied-on-dockersock-fix-it-without-sudo-50de</link>
      <guid>https://dev.to/deploylark/docker-permission-denied-on-dockersock-fix-it-without-sudo-50de</guid>
      <description>&lt;p&gt;You type &lt;code&gt;docker ps&lt;/code&gt; and get &lt;code&gt;permission denied while trying to connect to the docker API at unix:///var/run/docker.sock&lt;/code&gt;. With &lt;code&gt;sudo&lt;/code&gt; the same command works. Or the error shows up inside a container that has the socket mounted, and adding the user to the docker group changes nothing.&lt;/p&gt;

&lt;p&gt;This guide reproduces each version of the error on Ubuntu 24.04 with Docker Engine 29.9.3 and fixes it. Every command and output below comes from that lab run on November 5, 2026, with a normal user named &lt;code&gt;alice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You're not alone with this one. On Stack Overflow, &lt;a href="https://stackoverflow.com/questions/48957195/how-to-fix-docker-permission-denied" rel="noopener noreferrer"&gt;How to fix Docker: Permission denied&lt;/a&gt; had 1,945,544 views on November 5, 2026, and &lt;a href="https://stackoverflow.com/questions/47854463/docker-got-permission-denied-while-trying-to-connect-to-the-docker-daemon-socket" rel="noopener noreferrer"&gt;Got permission denied while trying to connect to the Docker daemon socket&lt;/a&gt; had 962,590.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your user can't open &lt;code&gt;/var/run/docker.sock&lt;/code&gt;, which is owned by &lt;code&gt;root:docker&lt;/code&gt; with mode &lt;code&gt;660&lt;/code&gt;. Run &lt;code&gt;sudo usermod -aG docker $USER&lt;/code&gt;, then log out and back in, or run &lt;code&gt;newgrp docker&lt;/code&gt;. A shell that was open before &lt;code&gt;usermod&lt;/code&gt; keeps failing.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;usermod&lt;/code&gt; says the group does not exist, run &lt;code&gt;sudo groupadd docker&lt;/code&gt;, add the user, and restart the Docker daemon so it sets the group on the socket.&lt;/li&gt;
&lt;li&gt;Inside a container, add the host's numeric group ID: &lt;code&gt;--group-add "$(stat -c %g /var/run/docker.sock)"&lt;/code&gt;, or &lt;code&gt;group_add&lt;/code&gt; in Compose. The group name &lt;code&gt;docker&lt;/code&gt; in the image usually has a different number.&lt;/li&gt;
&lt;li&gt;Don't &lt;code&gt;chmod 666&lt;/code&gt; the socket. The docker group is root-equivalent, so only add trusted users, and put a socket proxy in front of apps that only read container state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Docker says permission denied on docker.sock
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;docker&lt;/code&gt; command is only a client. It talks to the Docker daemon, &lt;code&gt;dockerd&lt;/code&gt;, through a Unix socket at &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. The daemon runs as root. Whoever can open that socket can tell it to do anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F01-docker-sock-permission-denied-terminal.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F01-docker-sock-permission-denied-terminal.webp" alt="Terminal on Ubuntu 24.04 with Docker 29.9.3. The id command shows alice only in group 1001(alice), docker ps fails with permission denied while trying to connect to the docker API at unix:///var/run/docker.sock, and ls -l shows the socket as srw-rw---- root docker. The older Docker CLI 26.1.5 long form of the error is shown below." width="800" height="167"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the socket permissions mean
&lt;/h3&gt;

&lt;p&gt;On a fresh install, the socket looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;srw-rw---- 1 root docker 0 Nov  5 02:17 /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only root and members of the &lt;code&gt;docker&lt;/code&gt; group can read and write it. In the lab, &lt;code&gt;alice&lt;/code&gt; had &lt;code&gt;groups=1001(alice)&lt;/code&gt;, so the kernel refused the connection before Docker saw the request. &lt;code&gt;sudo docker ps&lt;/code&gt; works because root owns the socket.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two forms of the error
&lt;/h3&gt;

&lt;p&gt;Docker CLI 29.9.3 prints a short message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older clients print the full API call. Docker CLI 26.1.5 on Debian 13 showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.45/containers/json": dial unix /var/run/docker.sock: connect: permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both mean the same thing, and &lt;code&gt;docker compose&lt;/code&gt; fails the same way, because it uses the same socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it on the host with the docker group
&lt;/h2&gt;

&lt;p&gt;The fix in the &lt;a href="https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user" rel="noopener noreferrer"&gt;Docker post-install guide&lt;/a&gt; is to add your user to the docker group:&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 &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone doesn't fix the shell you're typing in. This is the step most people miss.&lt;/p&gt;

&lt;h3&gt;
  
  
  Start a new session or run newgrp
&lt;/h3&gt;

&lt;p&gt;Linux reads your groups when you log in. A shell that started before &lt;code&gt;usermod&lt;/code&gt; keeps the old list. In the lab, &lt;code&gt;alice&lt;/code&gt; had a shell open, root ran &lt;code&gt;usermod -aG docker alice&lt;/code&gt;, and the old shell still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[old shell] $ id
uid=1001(alice) gid=1001(alice) groups=1001(alice)
[old shell] $ docker ps
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;newgrp docker&lt;/code&gt; started a subshell with the new group, and a fresh login (&lt;code&gt;su - alice&lt;/code&gt;, the same as a new SSH session) had it too. Both could run &lt;code&gt;docker ps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F03-docker-group-newgrp-new-login.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F03-docker-group-newgrp-new-login.webp" alt="Terminal showing that after usermod -aG docker alice the already open shell still lists only group 1001(alice) and docker ps is denied. After newgrp docker, id shows gid=996(docker) and docker ps prints the container table. A fresh login shows groups=1001(alice),996(docker) and docker ps works." width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;newgrp&lt;/code&gt; only changes that one subshell. For every terminal and your desktop session, log out fully and log back in. The Docker guide adds that a virtual machine may need a restart. Check with &lt;code&gt;id&lt;/code&gt; that &lt;code&gt;docker&lt;/code&gt; is in the list before you try again. Ubuntu created the group with GID 996 here. The number differs from machine to machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  usermod: group 'docker' does not exist
&lt;/h3&gt;

&lt;p&gt;If the docker group is missing, the daemon has no group to give the socket. The lab removed the group and restarted the daemon to reproduce it. &lt;code&gt;dockerd&lt;/code&gt; logged a warning and the socket fell back to &lt;code&gt;root:root&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;level=warning msg="could not change group /var/run/docker.sock to docker: group docker not found"
srw-rw---- 1 root root 0 Nov  5 02:18 /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;usermod&lt;/code&gt; then failed with &lt;code&gt;usermod: group 'docker' does not exist&lt;/code&gt; and exit code 6. Create the group first:&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 &lt;/span&gt;groupadd docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restart matters. Right after &lt;code&gt;groupadd&lt;/code&gt; the socket still belonged to &lt;code&gt;root:root&lt;/code&gt;, and &lt;code&gt;docker ps&lt;/code&gt; as &lt;code&gt;alice&lt;/code&gt; was still denied. Once the daemon came back up it set the socket to &lt;code&gt;root:docker&lt;/code&gt;, and &lt;code&gt;docker ps&lt;/code&gt; worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chmod 666 and sudo everywhere are the wrong fix
&lt;/h2&gt;

&lt;p&gt;Many answers suggest &lt;code&gt;sudo chmod 666 /var/run/docker.sock&lt;/code&gt;. It does make the error go away, because every user on the machine can now open the socket.&lt;/p&gt;

&lt;h3&gt;
  
  
  chmod 666 hands root to every account
&lt;/h3&gt;

&lt;p&gt;That is the problem. Any process on the host, including a compromised web app running as &lt;code&gt;www-data&lt;/code&gt;, can now start a privileged container. The change doesn't even last. In the lab the socket showed &lt;code&gt;srw-rw-rw-&lt;/code&gt; after &lt;code&gt;chmod 666&lt;/code&gt;, and after a daemon restart it was back to &lt;code&gt;srw-rw---- root docker&lt;/code&gt;. You end up with an insecure fix that disappears at the next daemon restart, so the error comes back.&lt;/p&gt;

&lt;h3&gt;
  
  
  The docker group is root too
&lt;/h3&gt;

&lt;p&gt;Adding a user to the docker group is the supported fix, but it gives the same power in a controlled way. The Docker guide is blunt about it: "The docker group grants root-level privileges to the user." The &lt;a href="https://docs.docker.com/engine/security/#docker-daemon-attack-surface" rel="noopener noreferrer"&gt;Docker security docs&lt;/a&gt; explain why. A container can mount the host's &lt;code&gt;/&lt;/code&gt; directory and change the host filesystem without any restriction.&lt;/p&gt;

&lt;p&gt;So treat docker group membership like sudo access. Add the accounts you'd trust with root, and nobody else. If you want Docker without root on the host at all, look at &lt;a href="https://docs.docker.com/engine/security/rootless/" rel="noopener noreferrer"&gt;rootless mode&lt;/a&gt;, where each user runs their own daemon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix permission denied inside a container
&lt;/h2&gt;

&lt;p&gt;The error often appears in containers that mount the socket: CI agents, Jenkins, &lt;a href="https://deploylark.com/posts/portainer-ce-install-stacks/" rel="noopener noreferrer"&gt;Portainer&lt;/a&gt;, &lt;a href="https://deploylark.com/posts/traefik-docker-labels-404-502/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt;, dashboards. As long as the container runs as root it works. Once the image switches to a normal user, it fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The group name inside the image doesn't match
&lt;/h3&gt;

&lt;p&gt;The kernel checks numeric IDs, not names. In the lab, the socket on the host belonged to GID 1002. A container running as user 1000 saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm --user 1000:1000 -v /var/run/docker.sock:/var/run/docker.sock docker:29.9.3-cli sh -c 'ls -ln /var/run/docker.sock &amp;amp;&amp;amp; getent group docker'
srw-rw----    1 0        1002             0 Nov  5 02:18 /var/run/docker.sock
docker:x:2375:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;docker:29.9.3-cli&lt;/code&gt; image has its own &lt;code&gt;docker&lt;/code&gt; group with GID 2375. So &lt;code&gt;--group-add docker&lt;/code&gt; adds group 2375, which doesn't own the socket, and &lt;code&gt;docker ps&lt;/code&gt; still fails. That explains the Stack Overflow question where &lt;a href="https://stackoverflow.com/questions/51342810/how-to-fix-dial-unix-var-run-docker-sock-connect-permission-denied-when-gr" rel="noopener noreferrer"&gt;group permissions seem correct&lt;/a&gt; (347,311 views). Pass the host's number instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; 1000:1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-add&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %g /var/run/docker.sock&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="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock &lt;span class="se"&gt;\&lt;/span&gt;
  docker:29.9.3-cli sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'id &amp;amp;&amp;amp; docker ps'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This printed &lt;code&gt;uid=1000 gid=1000 groups=1000,1002&lt;/code&gt; and the container list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F04-docker-sock-container-group-add-gid.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F04-docker-sock-container-group-add-gid.webp" alt="Terminal on the host. stat shows the socket is root:docker with GID 1002. docker:29.9.3-cli running as user 1000 is denied. Inside the image the socket shows group 1002 while the image's docker group is 2375, so --group-add docker is still denied. With --group-add set to the stat output, id shows groups=1000,1002 and docker ps prints the container list." width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Set group_add in Docker Compose
&lt;/h3&gt;

&lt;p&gt;In Compose, the same fix is &lt;a href="https://docs.docker.com/reference/compose-file/services/#group_add" rel="noopener noreferrer"&gt;group_add&lt;/a&gt;. Read the number from an environment variable, so the file works on hosts with different GIDs:&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;ci-agent&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;docker:29.9.3-cli&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&lt;/span&gt;
    &lt;span class="na"&gt;group_add&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;${DOCKER_GID:?set&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DOCKER_GID&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;GID&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/var/run/docker.sock}"&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;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sleep infinity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then write the value into &lt;code&gt;.env&lt;/code&gt; next to the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DOCKER_GID=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %g /var/run/docker.sock&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;.env&lt;/code&gt;, Compose stopped right away with &lt;code&gt;required variable DOCKER_GID is missing a value&lt;/code&gt;, which beats a container that starts and then fails. With it, &lt;code&gt;id&lt;/code&gt; inside &lt;code&gt;ci-agent&lt;/code&gt; showed &lt;code&gt;groups=1000,1002&lt;/code&gt; and &lt;code&gt;docker ps&lt;/code&gt; listed the containers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F02-docker-sock-permission-diagram.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F02-docker-sock-permission-diagram.webp" alt="Diagram of who can open /var/run/docker.sock, owned by root with group docker 1002 and mode 0660. Allowed: alice after a new login with group 1002, and a container user 1000 with --group-add 1002. Denied: alice's old shell with only group 1001, and a container with --group-add docker, which resolves to GID 2375 inside the image." width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Give containers less than the full socket
&lt;/h2&gt;

&lt;p&gt;A container with the socket has root on your host, so &lt;code&gt;group_add&lt;/code&gt; doesn't make it safer. It only lets a non-root process use that power. Mounting the socket with &lt;code&gt;:ro&lt;/code&gt; doesn't limit the API either. Many apps, like &lt;a href="https://deploylark.com/posts/gethomepage-docker-config/" rel="noopener noreferrer"&gt;Homepage&lt;/a&gt; or &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt;, only need to list containers. A socket proxy covers that case. Its &lt;a href="https://github.com/Tecnativa/docker-socket-proxy#why" rel="noopener noreferrer"&gt;README&lt;/a&gt; puts the risk plainly: "Giving access to your Docker socket could mean giving root access to your host, or even to your whole swarm".&lt;/p&gt;

&lt;h3&gt;
  
  
  Run docker-socket-proxy with read-only access
&lt;/h3&gt;

&lt;p&gt;This Compose file puts &lt;code&gt;tecnativa/docker-socket-proxy:v0.5.0&lt;/code&gt; on an internal network. The app container runs as user 1000, has no socket mount and no group tricks, and talks to the proxy over TCP:&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;socket-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;tecnativa/docker-socket-proxy:v0.5.0&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;CONTAINERS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;/var/run/docker.sock:/var/run/docker.sock: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;socket&lt;/span&gt;

  &lt;span class="na"&gt;client&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;docker:29.9.3-cli&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&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;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://socket-proxy:2375&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sleep infinity&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;socket&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;socket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;internal&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;CONTAINERS: 1&lt;/code&gt; allows the container endpoints, and &lt;code&gt;POST: 0&lt;/code&gt; allows only GET and HEAD requests. Don't publish port 2375 to the host.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a 403 from the proxy means
&lt;/h3&gt;

&lt;p&gt;From the client, &lt;code&gt;docker ps&lt;/code&gt; listed both containers. Everything else was refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker compose exec client docker run --rm docker:29.9.3-cli true
docker: Error response from daemon: &amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;h1&amp;gt;403 Forbidden&amp;lt;/h1&amp;gt;
Request forbidden by administrative rules.
&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker images&lt;/code&gt; failed the same way, because &lt;code&gt;IMAGES&lt;/code&gt; was not enabled, and so did &lt;code&gt;docker stop&lt;/code&gt;, because it's a POST. The proxy log shows each blocked call, such as &lt;code&gt;"POST /v1.56/containers/create HTTP/1.1"&lt;/code&gt; with status 403. When an app behind the proxy fails with this error, find the API section in its log and enable only that variable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F05-docker-socket-proxy-403-forbidden.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fdocker-sock-permission-denied%2F05-docker-socket-proxy-403-forbidden.webp" alt="Terminal with docker-socket-proxy v0.5.0 set to CONTAINERS=1 and POST=0. docker ps from the client container lists the proxy and client containers. docker run returns Error response from daemon 403 Forbidden, Request forbidden by administrative rules. The proxy log shows 403 for GET images/json, POST containers/create and POST containers stop." width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors that look like permission denied but aren't
&lt;/h2&gt;

&lt;p&gt;Two other messages show up in the same searches. They need different fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  failed to connect to the docker API, is the daemon running
&lt;/h3&gt;

&lt;p&gt;With the daemon stopped, Docker 29.9.3 printed one long line. It starts with &lt;code&gt;failed to connect to the docker API at unix:///var/run/docker.sock&lt;/code&gt; and ends like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older clients say &lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?&lt;/code&gt;, a question with &lt;a href="https://stackoverflow.com/questions/44678725/cannot-connect-to-the-docker-daemon-at-unix-var-run-docker-sock-is-the-docker" rel="noopener noreferrer"&gt;3.3 million views&lt;/a&gt;. The key part is &lt;code&gt;no such file or directory&lt;/code&gt;. The socket isn't there, so start the daemon with &lt;code&gt;sudo systemctl start docker&lt;/code&gt; and check &lt;code&gt;sudo systemctl status docker&lt;/code&gt; if it won't stay up.&lt;/p&gt;

&lt;p&gt;A leftover &lt;code&gt;DOCKER_HOST&lt;/code&gt; gives the same error with a different path. In the lab, &lt;code&gt;DOCKER_HOST=unix:///run/user/1001/docker.sock&lt;/code&gt;, a rootless socket that didn't exist, made &lt;code&gt;docker ps&lt;/code&gt; fail even though the system daemon was running. &lt;code&gt;docker context ls&lt;/code&gt; warned that the variable overrides the active context. &lt;code&gt;unset DOCKER_HOST&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  WARNING: Error loading config file
&lt;/h3&gt;

&lt;p&gt;If you ran &lt;code&gt;sudo docker login&lt;/code&gt; or other &lt;code&gt;sudo docker&lt;/code&gt; commands before, &lt;code&gt;~/.docker&lt;/code&gt; may belong to root. In the lab, &lt;code&gt;docker ps&lt;/code&gt; as &lt;code&gt;alice&lt;/code&gt; still worked but printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARNING: Error loading config file: open /home/alice/.docker/config.json: permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker ignores your config, including registry logins, until you take the folder back:&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 chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.docker"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warning was gone on the next run. For more permission errors, the &lt;a href="https://deploylark.com/posts/grafana-prometheus-docker-permission-denied/" rel="noopener noreferrer"&gt;Grafana and Prometheus volume guide&lt;/a&gt; covers bind mount ownership, which is a different problem from the socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Running Docker without sudo
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I run Docker commands without sudo?
&lt;/h4&gt;

&lt;p&gt;Add your user to the docker group with &lt;code&gt;sudo usermod -aG docker $USER&lt;/code&gt;, then log out and back in or run &lt;code&gt;newgrp docker&lt;/code&gt;. Check with &lt;code&gt;id&lt;/code&gt; that &lt;code&gt;docker&lt;/code&gt; appears in your groups. If you'd rather not give your account root-equivalent access, use rootless mode instead.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does docker work with sudo but not without it?
&lt;/h4&gt;

&lt;p&gt;The socket is owned by &lt;code&gt;root:docker&lt;/code&gt; with mode &lt;code&gt;660&lt;/code&gt;. Root can always open it, so &lt;code&gt;sudo docker&lt;/code&gt; works. Your user can only open it as a member of the docker group in the current session.&lt;/p&gt;

&lt;h4&gt;
  
  
  I added my user to the docker group. Why do I still get permission denied?
&lt;/h4&gt;

&lt;p&gt;Your current shell still has the old group list. Open a new login session or run &lt;code&gt;newgrp docker&lt;/code&gt;. If &lt;code&gt;id&lt;/code&gt; shows the group and it still fails, check &lt;code&gt;ls -l /var/run/docker.sock&lt;/code&gt;. A socket owned by &lt;code&gt;root:root&lt;/code&gt; means the daemon started before the group existed, so restart it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security and containers
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Is chmod 666 on docker.sock safe?
&lt;/h4&gt;

&lt;p&gt;No. It lets every account and service on the host control Docker, which means root on the host. It also resets when the daemon restarts, so it doesn't even fix the problem for long.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is adding a user to the docker group a security risk?
&lt;/h4&gt;

&lt;p&gt;Yes, in the same way as giving that user sudo. The Docker docs state that the group grants root-level privileges. Only add trusted users, or use rootless mode.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I fix docker.sock permission denied in a container?
&lt;/h4&gt;

&lt;p&gt;Run the container with the socket's numeric group: &lt;code&gt;--group-add "$(stat -c %g /var/run/docker.sock)"&lt;/code&gt;, or &lt;code&gt;group_add&lt;/code&gt; in Compose. A group name like &lt;code&gt;docker&lt;/code&gt; resolves inside the image and usually has the wrong number. If the app only reads container state, give it a socket proxy instead of the socket.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>selfhosted</category>
      <category>security</category>
    </item>
    <item>
      <title>Self-Host Supabase with Docker: Studio, API Keys and Errors</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:02:47 +0000</pubDate>
      <link>https://dev.to/deploylark/self-host-supabase-with-docker-studio-api-keys-and-errors-6o0</link>
      <guid>https://dev.to/deploylark/self-host-supabase-with-docker-studio-api-keys-and-errors-6o0</guid>
      <description>&lt;p&gt;Supabase is an open source backend built on Postgres. It bundles a database, Auth, an auto-generated REST API, Storage, Realtime and Edge Functions, with Studio as the web dashboard. The &lt;a href="https://github.com/supabase/supabase" rel="noopener noreferrer"&gt;supabase/supabase repository&lt;/a&gt; had about 113,000 GitHub stars on October 22, 2026, and the &lt;code&gt;supabase/postgres&lt;/code&gt; image had passed 19 million pulls on Docker Hub.&lt;/p&gt;

&lt;p&gt;This guide installs the official self-hosted release v0.8.3 with Docker Compose, generates the keys the official way, creates a table in Studio and reads it over the REST API with the anon key. It then covers the errors that usually come next: 401 from the gateway, &lt;code&gt;PGRST301&lt;/code&gt;, an empty &lt;code&gt;[]&lt;/code&gt; from Row Level Security, Storage upload errors and signups that never finish.&lt;/p&gt;

&lt;p&gt;TL;DR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clone the &lt;code&gt;self-hosted/v0.8.3&lt;/code&gt; tag, copy &lt;code&gt;docker/&lt;/code&gt; to a project folder, run &lt;code&gt;sh utils/generate-keys.sh --update-env&lt;/code&gt; and &lt;code&gt;sh utils/add-new-auth-keys.sh --update-env&lt;/code&gt;, then &lt;code&gt;docker compose up -d --wait&lt;/code&gt;. All 11 containers reported healthy in 37 seconds.&lt;/li&gt;
&lt;li&gt;Studio and every API sit behind one gateway on port 8000. Studio uses HTTP basic auth with &lt;code&gt;DASHBOARD_USERNAME&lt;/code&gt; and &lt;code&gt;DASHBOARD_PASSWORD&lt;/code&gt; from &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every API call needs an &lt;code&gt;apikey&lt;/code&gt; header. Without it the gateway returns &lt;code&gt;401 Unauthorized&lt;/code&gt;. A token signed with a different &lt;code&gt;JWT_SECRET&lt;/code&gt; returns &lt;code&gt;PGRST301&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tables created in Studio have RLS on. With no policy, the anon key gets &lt;code&gt;[]&lt;/code&gt; with HTTP 200, not an error. Add a policy for the &lt;code&gt;anon&lt;/code&gt; role.&lt;/li&gt;
&lt;li&gt;Self-hosted Supabase is one project, and branching, PITR, managed backups, ETL and the Management API stay on the managed platform. Back up Postgres yourself before you run &lt;code&gt;update.sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What self-hosted Supabase includes, and what stays on Cloud
&lt;/h2&gt;

&lt;p&gt;The self-hosted stack is the same set of open source services the managed platform runs, wired together by one Compose file. The v0.8.3 file starts 11 containers: Studio, the Envoy API gateway, Auth (GoTrue), PostgREST, Realtime, Storage, imgproxy, postgres-meta, Edge Runtime, Postgres and the Supavisor connection pooler. Logs and analytics (Logflare and Vector) are an optional override that you add with &lt;code&gt;sh run.sh config add logs&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud-only features
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://supabase.com/docs/guides/self-hosting" rel="noopener noreferrer"&gt;self-hosting overview&lt;/a&gt; lists what you give up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Some features of the managed platform aren't available when self-hosting: branching, advanced metrics beyond logs, managed backups and PITR, analytics and vector buckets, ETL, and the Management API.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same page says self-hosted Supabase "runs as a single project, which means that Studio doesn't support multiple organizations or projects". Most settings live in &lt;code&gt;.env&lt;/code&gt; instead of dashboard screens. The overview also states that self-hosted Supabase "does not phone home or collect any telemetry".&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware and ports
&lt;/h3&gt;

&lt;p&gt;Per the &lt;a href="https://supabase.com/docs/guides/self-hosting/docker" rel="noopener noreferrer"&gt;Docker guide&lt;/a&gt;, the minimum for all components is 4 GB RAM, 2 CPU cores and a 40 GB SSD, with 8 GB, 4 cores and 80 GB recommended. The pinned images in v0.8.3 took about 6.6 GB of disk before any data, with Studio (1.28 GB), Postgres (1.24 GB) and Supavisor (1.17 GB) the largest.&lt;/p&gt;

&lt;p&gt;The default file publishes three host ports:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8000&lt;/td&gt;
&lt;td&gt;Envoy gateway&lt;/td&gt;
&lt;td&gt;Studio plus &lt;code&gt;/rest/v1&lt;/code&gt;, &lt;code&gt;/auth/v1&lt;/code&gt;, &lt;code&gt;/storage/v1&lt;/code&gt;, &lt;code&gt;/realtime/v1&lt;/code&gt;, &lt;code&gt;/functions/v1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;Supavisor&lt;/td&gt;
&lt;td&gt;Postgres connections in session mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6543&lt;/td&gt;
&lt;td&gt;Supavisor&lt;/td&gt;
&lt;td&gt;Postgres connections in transaction mode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Envoy is the default gateway in v0.8.3, and the 401 responses below carry &lt;code&gt;server: envoy&lt;/code&gt;. Kong is still available as an override with &lt;code&gt;sh run.sh config add kong&lt;/code&gt;. To move the gateway port, change &lt;code&gt;API_GW_HTTP_PORT&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;. The old &lt;code&gt;KONG_HTTP_PORT&lt;/code&gt; variable is kept only as a fallback so older &lt;code&gt;.env&lt;/code&gt; files keep working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Supabase with Docker Compose
&lt;/h2&gt;

&lt;p&gt;You need Docker with the Compose plugin and &lt;code&gt;git&lt;/code&gt;. The steps below are the sparse clone from the Docker guide, which pins the release tag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clone the pinned release
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--no-checkout&lt;/span&gt; &lt;span class="nt"&gt;--depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--branch&lt;/span&gt; self-hosted/v0.8.3 https://github.com/supabase/supabase
&lt;span class="nb"&gt;cd &lt;/span&gt;supabase
git sparse-checkout init &lt;span class="nt"&gt;--cone&lt;/span&gt;
git sparse-checkout &lt;span class="nb"&gt;set &lt;/span&gt;docker
git checkout &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ..

&lt;span class="nb"&gt;mkdir &lt;/span&gt;supabase-project
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; supabase/docker/. supabase-project
&lt;span class="nb"&gt;cd &lt;/span&gt;supabase-project &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'ref=self-hosted/v0.8.3\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .supabase-version
docker compose pull
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag pointed at commit &lt;code&gt;bc27d2661edd09a5d0b8d76ce33da6d4071a756e&lt;/code&gt; from October 16, 2026. The &lt;code&gt;.supabase-version&lt;/code&gt; file matters later, because &lt;code&gt;update.sh&lt;/code&gt; reads it to know which release you started from. These are the image tags that release pins in &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;studio&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;supabase/studio:2026.10.12-sha-3699278&lt;/span&gt;
  &lt;span class="na"&gt;api-gw&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;envoyproxy/envoy:v1.39.1&lt;/span&gt;
  &lt;span class="na"&gt;auth&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;supabase/gotrue:v2.197.0&lt;/span&gt;
  &lt;span class="na"&gt;rest&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;postgrest/postgrest:v14.18&lt;/span&gt;
  &lt;span class="na"&gt;realtime&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;supabase/realtime:v2.143.3&lt;/span&gt;
  &lt;span class="na"&gt;storage&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;supabase/storage-api:v1.81.0&lt;/span&gt;
  &lt;span class="na"&gt;imgproxy&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;darthsim/imgproxy:v3.31.4&lt;/span&gt;
  &lt;span class="na"&gt;meta&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;supabase/postgres-meta:v0.100.0&lt;/span&gt;
  &lt;span class="na"&gt;functions&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;supabase/edge-runtime:v1.77.4&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;supabase/postgres:17.6.1.136&lt;/span&gt;
  &lt;span class="na"&gt;supavisor&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;supabase/supavisor:2.9.13&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Generate JWT_SECRET, ANON_KEY and SERVICE_ROLE_KEY
&lt;/h3&gt;

&lt;p&gt;Never start the stack with the placeholder values from &lt;code&gt;.env.example&lt;/code&gt;. The repo ships two scripts that write fresh secrets straight into &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sh utils/generate-keys.sh &lt;span class="nt"&gt;--update-env&lt;/span&gt;
sh utils/add-new-auth-keys.sh &lt;span class="nt"&gt;--update-env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first script creates &lt;code&gt;JWT_SECRET&lt;/code&gt;, the legacy &lt;code&gt;ANON_KEY&lt;/code&gt; and &lt;code&gt;SERVICE_ROLE_KEY&lt;/code&gt; (HS256 tokens signed with that secret, valid for five years), &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;, &lt;code&gt;DASHBOARD_PASSWORD&lt;/code&gt; and the other internal secrets. The second adds the newer &lt;code&gt;SUPABASE_PUBLISHABLE_KEY&lt;/code&gt; (&lt;code&gt;sb_publishable_...&lt;/code&gt;) and &lt;code&gt;SUPABASE_SECRET_KEY&lt;/code&gt; (&lt;code&gt;sb_secret_...&lt;/code&gt;) plus an ES256 key pair, and it uncomments the lines in &lt;code&gt;docker-compose.yml&lt;/code&gt; that pass the key pair to Auth, Realtime, Storage and Edge Functions. It needs Node 16 or newer, or it runs inside a &lt;code&gt;node:22-alpine&lt;/code&gt; container that it pulls on first use. The legacy anon key and the new publishable key both worked in the &lt;code&gt;apikey&lt;/code&gt; header. &lt;code&gt;sh run.sh secrets&lt;/code&gt; prints the database and dashboard passwords, the publishable and secret keys and the S3 keys. Read &lt;code&gt;ANON_KEY&lt;/code&gt;, &lt;code&gt;SERVICE_ROLE_KEY&lt;/code&gt; and &lt;code&gt;JWT_SECRET&lt;/code&gt; straight from &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then set the URLs in &lt;code&gt;.env&lt;/code&gt;. For a local install the defaults work: &lt;code&gt;SUPABASE_PUBLIC_URL=http://localhost:8000&lt;/code&gt;, &lt;code&gt;API_EXTERNAL_URL&lt;/code&gt; pointing at the same host, and &lt;code&gt;SITE_URL=http://localhost:3000&lt;/code&gt; for your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start the stack and create a table in Studio
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keep the ports on localhost and start the stack
&lt;/h3&gt;

&lt;p&gt;The default ports bind to every interface. Studio has only basic auth in front of it, so keep everything on the loopback until a reverse proxy is in place. An override file with the Compose &lt;code&gt;!override&lt;/code&gt; tag replaces the port lists:&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="c1"&gt;# docker-compose.local.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api-gw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!override&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:8000:8000/tcp&lt;/span&gt;
  &lt;span class="na"&gt;supavisor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!override&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:5432:5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:6543:6543&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it to the &lt;code&gt;COMPOSE_FILE&lt;/code&gt; line in &lt;code&gt;.env&lt;/code&gt; so every &lt;code&gt;docker compose&lt;/code&gt; and &lt;code&gt;run.sh&lt;/code&gt; command layers it:&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="nv"&gt;COMPOSE_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker-compose.yml:docker-compose.local.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot do this with &lt;code&gt;POSTGRES_PORT=127.0.0.1:5432&lt;/code&gt;, because the Compose file also passes that variable to services as a plain port number. Now start everything and wait for the health checks:&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 up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sh run.sh start&lt;/code&gt; runs the same &lt;code&gt;up -d --wait&lt;/code&gt;. The command returned after 37 seconds with all 11 containers healthy, and the pooler and gateway listened only on &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F05-compose-ps-docker-stats.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F05-compose-ps-docker-stats.webp" alt="Terminal output of docker compose ps showing 11 Supabase containers healthy with pinned image tags, followed by docker stats with per-container memory at idle, from 10.52 MiB for Auth to 247.7 MiB for Realtime" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At idle the 11 containers used about 1.1 GiB of RAM added together and under 1% CPU each. Realtime (248 MiB), Studio (202 MiB) and Supavisor (193 MiB) were the largest, while Auth used about 11 MiB and PostgREST about 18 MiB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create a table and read it over REST
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;http://localhost:8000&lt;/code&gt;. The browser asks for a username and password. That is the HTTP basic auth from &lt;code&gt;DASHBOARD_USERNAME&lt;/code&gt; (default &lt;code&gt;supabase&lt;/code&gt;) and the generated &lt;code&gt;DASHBOARD_PASSWORD&lt;/code&gt;. Studio opens straight into a single project called Default Project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F01-studio-project-overview.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F01-studio-project-overview.webp" alt="Supabase Studio Project Overview page for the self-hosted Default Project at http://localhost:8000 with Get connected shortcuts and an advisor panel showing no issues" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Table Editor, choose New table, name it &lt;code&gt;notes&lt;/code&gt;, keep the default &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;created_at&lt;/code&gt; columns and add a &lt;code&gt;title&lt;/code&gt; column of type &lt;code&gt;text&lt;/code&gt;. Leave Enable Row Level Security checked. Save, then use Insert, Insert row and enter a title.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F02-table-editor-notes-row.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F02-table-editor-notes-row.webp" alt="Supabase Studio Table Editor showing the public.notes table with id, created_at and title columns and one row titled Hello from self-hosted Supabase" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PostgREST serves every table in the &lt;code&gt;public&lt;/code&gt; schema under &lt;code&gt;/rest/v1&lt;/code&gt;. Read the anon key from &lt;code&gt;.env&lt;/code&gt; and query the table:&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;export &lt;/span&gt;&lt;span class="nv"&gt;ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^ANON_KEY='&lt;/span&gt; .env | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2-&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:8000/rest/v1/notes?select=id,title"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first answer was &lt;code&gt;[]&lt;/code&gt; with HTTP 200, even though the row is there. That is RLS doing its job, covered in the next section. After adding a read policy, the same call returned the row:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Hello from self-hosted Supabase"&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;p&gt;The publishable key works the same way in the &lt;code&gt;apikey&lt;/code&gt; header. The service role key bypasses RLS and returned the row even before the policy existed. The secret key bypassed RLS the same way for a Storage upload later in this guide. Keep both on servers only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix API key, RLS, Storage and signup errors
&lt;/h2&gt;

&lt;p&gt;Each error below came up during setup, with the change that cleared it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F03-rest-api-anon-key-rls.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F03-rest-api-anon-key-rls.webp" alt="Terminal showing curl calls to the self-hosted Supabase REST API: 401 without an apikey, the notes row returned with the anon key, and a 42501 row-level security error on an anon insert" width="800" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  API keys and RLS: 401, PGRST301, [] and 42501
&lt;/h3&gt;

&lt;p&gt;A request with no &lt;code&gt;apikey&lt;/code&gt; header, or with a value that is not one of your keys, stops at the gateway. Envoy answers &lt;code&gt;HTTP/1.1 401 Unauthorized&lt;/code&gt; with the plain body &lt;code&gt;Unauthorized&lt;/code&gt;. The fix is to send the key in the &lt;code&gt;apikey&lt;/code&gt; header on every request, including from your own backend.&lt;/p&gt;

&lt;p&gt;A valid &lt;code&gt;apikey&lt;/code&gt; with a bad &lt;code&gt;Authorization: Bearer&lt;/code&gt; token gets further and fails in PostgREST instead:&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"PGRST301"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"None of the keys was able to decode the JWT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"No suitable key or wrong key type"&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;p&gt;The same error, with HTTP 401, appeared after replacing &lt;code&gt;ANON_KEY&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; with a token signed by a different secret and recreating the gateway, Auth and REST. The gateway accepted it because it matched &lt;code&gt;.env&lt;/code&gt;, and PostgREST rejected it because it was not signed with &lt;code&gt;JWT_SECRET&lt;/code&gt;. Auth kept answering &lt;code&gt;/auth/v1/settings&lt;/code&gt; normally the whole time, so a working Auth endpoint does not prove your keys match. Putting the generated keys back and running &lt;code&gt;docker compose up -d --force-recreate api-gw rest auth&lt;/code&gt; returned the row again. If you ever change &lt;code&gt;JWT_SECRET&lt;/code&gt;, run &lt;code&gt;generate-keys.sh&lt;/code&gt; again so all three values are regenerated together, then &lt;code&gt;sh run.sh recreate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Studio enables RLS on new tables, and a table with RLS and no policy returns no rows to &lt;code&gt;anon&lt;/code&gt; without any error. Studio warns about this when you create the table. Add a policy in the SQL Editor or with &lt;code&gt;psql&lt;/code&gt; inside the &lt;code&gt;supabase-db&lt;/code&gt; container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Public can read notes"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That policy only covers reads. An anon &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/rest/v1/notes&lt;/code&gt; still failed with HTTP 401, which is the intended result:&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"42501"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"new row violates row-level security policy for table &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;notes&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a separate &lt;code&gt;insert&lt;/code&gt; policy for the role that should write, usually &lt;code&gt;authenticated&lt;/code&gt;, instead of disabling RLS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage and signup: NoSuchBucket, AccessDenied and 504
&lt;/h3&gt;

&lt;p&gt;Storage and Auth fail in less obvious ways, because the HTTP status or the log line does not always match the real cause.&lt;/p&gt;

&lt;p&gt;Storage errors come back with HTTP 400 and the real status inside the JSON body, which trips up clients that only check the status code. Uploading to a bucket that did not exist yet returned:&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;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"404"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Bucket not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Bucket not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NoSuchBucket"&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;p&gt;After creating a private bucket called &lt;code&gt;docs&lt;/code&gt; with the service role key, an upload with the anon key returned &lt;code&gt;{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy","code":"AccessDenied"}&lt;/code&gt;. Storage keeps object metadata in Postgres, so the same RLS rules apply. Uploading with the service role key succeeded, and so did an upload with the &lt;code&gt;sb_secret_&lt;/code&gt; key in the &lt;code&gt;apikey&lt;/code&gt; header. The file showed up in Studio. For uploads from the browser, add policies on &lt;code&gt;storage.objects&lt;/code&gt; per the &lt;a href="https://supabase.com/docs/guides/storage/security/access-control" rel="noopener noreferrer"&gt;Storage access control docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F04-storage-bucket-upload.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fsupabase-self-host-docker%2F04-storage-bucket-upload.webp" alt="Supabase Studio Storage page showing the file hello.txt in the docs bucket" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A fresh install has email signups on and confirmations required, and &lt;code&gt;.env&lt;/code&gt; points &lt;code&gt;SMTP_HOST&lt;/code&gt; at &lt;code&gt;supabase-mail&lt;/code&gt; on port 2500. No container with that name exists in the Compose file. A &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/auth/v1/signup&lt;/code&gt; hung for 10 seconds and returned &lt;code&gt;{"code":504,"error_code":"request_timeout",...}&lt;/code&gt;. &lt;a href="https://learnwithhasan.com/guide/self-host-supabase/" rel="noopener noreferrer"&gt;Other write-ups&lt;/a&gt; show the same root cause as a 500 with &lt;code&gt;Error sending confirmation email&lt;/code&gt; and a &lt;code&gt;lookup supabase-mail&lt;/code&gt; DNS error in the Auth logs.&lt;/p&gt;

&lt;p&gt;For a production install, set real &lt;code&gt;SMTP_HOST&lt;/code&gt;, &lt;code&gt;SMTP_PORT&lt;/code&gt;, &lt;code&gt;SMTP_USER&lt;/code&gt;, &lt;code&gt;SMTP_PASS&lt;/code&gt; and &lt;code&gt;SMTP_ADMIN_EMAIL&lt;/code&gt; values, then recreate Auth. For a local test stack, setting &lt;code&gt;ENABLE_EMAIL_AUTOCONFIRM=true&lt;/code&gt; and running &lt;code&gt;docker compose up -d --force-recreate auth&lt;/code&gt; made the next signup return HTTP 200 with a session. Do not leave autoconfirm on for real users. A plain &lt;code&gt;docker compose restart auth&lt;/code&gt; does not reread &lt;code&gt;.env&lt;/code&gt;. After a restart the Auth container still had the old &lt;code&gt;GOTRUE_MAILER_AUTOCONFIRM&lt;/code&gt; value, and only a recreate picked up the change. Use &lt;code&gt;sh run.sh recreate &amp;lt;service&amp;gt;&lt;/code&gt; after every &lt;code&gt;.env&lt;/code&gt; edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS, backups and upgrades
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Set SITE_URL and add HTTPS
&lt;/h3&gt;

&lt;p&gt;Everything above runs on &lt;code&gt;localhost&lt;/code&gt;. Once you put Supabase on a domain, three URL variables in &lt;code&gt;.env&lt;/code&gt; have to change together, per the &lt;a href="https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https" rel="noopener noreferrer"&gt;reverse proxy guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SUPABASE_PUBLIC_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://supabase.example.com
&lt;span class="nv"&gt;API_EXTERNAL_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://supabase.example.com/auth/v1
&lt;span class="nv"&gt;SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SITE_URL&lt;/code&gt; is where Auth sends users after email links and OAuth logins when no other address is given. A &lt;code&gt;redirect_to&lt;/code&gt; address has to match an entry in &lt;code&gt;ADDITIONAL_REDIRECT_URLS&lt;/code&gt;, which Auth uses as its allow list. Otherwise Auth sends the user to &lt;code&gt;SITE_URL&lt;/code&gt; instead. The &lt;a href="https://supabase.com/docs/guides/auth/redirect-urls" rel="noopener noreferrer"&gt;redirect URLs docs&lt;/a&gt; allow wildcard patterns such as &lt;code&gt;https://app.example.com/**&lt;/code&gt;, but recommend exact URLs in production.&lt;/p&gt;

&lt;p&gt;The default Envoy gateway does not terminate TLS, so HTTPS comes from a proxy in front of it. v0.8.3 ships two proxy overrides. Per the guide, &lt;code&gt;sh run.sh config add caddy&lt;/code&gt; followed by &lt;code&gt;sh run.sh start&lt;/code&gt; adds Caddy with automatic Let's Encrypt certificates, configured in &lt;code&gt;volumes/proxy/caddy/Caddyfile&lt;/code&gt;. &lt;code&gt;sh run.sh config add nginx&lt;/code&gt; adds Nginx with Certbot instead, and it also needs &lt;code&gt;PROXY_DOMAIN&lt;/code&gt; and &lt;code&gt;CERTBOT_EMAIL&lt;/code&gt; set in &lt;code&gt;.env&lt;/code&gt;. Both need a DNS record pointing at the server and ports 80 and 443 open. The guide's check is &lt;code&gt;curl -I https://&amp;lt;your-domain&amp;gt;/auth/v1/&lt;/code&gt;, which should answer 401.&lt;/p&gt;

&lt;p&gt;The guide also allows Nginx Proxy Manager, Traefik, HAProxy or another proxy you already run. With &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt;, add a proxy host that forwards to port 8000, turn on WebSockets support for Realtime, pass the &lt;code&gt;X-Forwarded&lt;/code&gt; headers, and remove the gateway's host port binding if the proxy shares the Docker network. Keep Studio's basic auth in front of the dashboard either way, or add an access list in your proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Back up and upgrade with update.sh
&lt;/h3&gt;

&lt;p&gt;The updating guide warns that &lt;code&gt;update.sh&lt;/code&gt; "backs up your configuration files to &lt;code&gt;backups/&lt;/code&gt;, but it does not back up Postgres or Storage data". Managed backups and PITR are Cloud features, so backups are your job.&lt;/p&gt;

&lt;p&gt;A logical dump of your own schema runs inside the database container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;supabase-db pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; postgres &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; public-backup.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dump contained the &lt;code&gt;notes&lt;/code&gt; table, its row and the &lt;code&gt;Public can read notes&lt;/code&gt; policy. Storage files live on disk under &lt;code&gt;volumes/storage&lt;/code&gt;, and the database files under &lt;code&gt;volumes/db/data&lt;/code&gt;, so copy both directories with the stack stopped for a full file-level backup.&lt;/p&gt;

&lt;p&gt;Per the &lt;a href="https://supabase.com/docs/guides/self-hosting/updating" rel="noopener noreferrer"&gt;updating guide&lt;/a&gt;, back up first, then preview and apply the next release, then pull and recreate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sh update.sh &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
sh update.sh
sh run.sh pull
sh run.sh recreate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;update.sh&lt;/code&gt; does a three-way merge against the release in &lt;code&gt;.supabase-version&lt;/code&gt;, keeps your &lt;code&gt;.env&lt;/code&gt;, and stops with exit code 2 and merge markers if your edits conflict with upstream changes. Releases that need a manual step, such as a Postgres major upgrade, are flagged before anything is applied. &lt;code&gt;sh reset.sh&lt;/code&gt; is the opposite tool. It runs &lt;code&gt;docker compose down -v&lt;/code&gt; and deletes the data directories, so never run it on an install you care about.&lt;/p&gt;

&lt;p&gt;If you want a broader self-hosted setup around this, &lt;a href="https://deploylark.com/posts/coolify-install-vps-first-app/" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; can deploy the app that talks to Supabase, and &lt;a href="https://deploylark.com/posts/portainer-ce-install-stacks/" rel="noopener noreferrer"&gt;Portainer&lt;/a&gt; gives you a web view of the 11 containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Setup, limits and login
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How much RAM does self-hosted Supabase need?
&lt;/h4&gt;

&lt;p&gt;The docs ask for 4 GB of RAM and 2 cores at minimum, with 8 GB and 4 cores recommended. The full default stack of 11 containers used about 1.1 GiB at idle, and the images took about 6.6 GB of disk. You can remove Realtime, Storage, imgproxy or Edge Functions from the Compose file to save memory, per the Docker guide.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can one self-hosted Supabase run multiple projects?
&lt;/h4&gt;

&lt;p&gt;No. The self-hosting overview says Studio "doesn't support multiple organizations or projects". Users who run two stacks on one host report in &lt;a href="https://github.com/supabase/supabase/issues/37412" rel="noopener noreferrer"&gt;GitHub issue #37412&lt;/a&gt; that each copy needs its own &lt;code&gt;.env&lt;/code&gt;, ports and renamed &lt;code&gt;container_name&lt;/code&gt; values.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does my new dashboard password not work?
&lt;/h4&gt;

&lt;p&gt;Studio's login is HTTP basic auth enforced at the gateway, which reads &lt;code&gt;DASHBOARD_USERNAME&lt;/code&gt; and &lt;code&gt;DASHBOARD_PASSWORD&lt;/code&gt; from its environment when the container is created. A restart keeps the old environment, so run &lt;code&gt;sh run.sh recreate&lt;/code&gt; after editing &lt;code&gt;.env&lt;/code&gt;. The Docker guide also says the password "must include at least one letter" and should not use numbers only or special characters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keys, email and redirects
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does REST return "No suitable key or wrong key type" while Auth works?
&lt;/h4&gt;

&lt;p&gt;PostgREST could not verify the JWT with your &lt;code&gt;JWT_SECRET&lt;/code&gt; or JWKS. A token signed by another secret produced exactly that &lt;code&gt;PGRST301&lt;/code&gt; error, while &lt;code&gt;/auth/v1/settings&lt;/code&gt; kept working. Regenerate the keys together with &lt;code&gt;generate-keys.sh&lt;/code&gt;, recreate the stack, and send a user's JWT, not the secret key, as the Bearer token.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does signup fail with "Error sending confirmation email"?
&lt;/h4&gt;

&lt;p&gt;The default &lt;code&gt;.env&lt;/code&gt; points SMTP at a &lt;code&gt;supabase-mail&lt;/code&gt; host that the Compose file does not create. Here the signup timed out with a 504 after 10 seconds. Set real SMTP values and recreate Auth, or use &lt;code&gt;ENABLE_EMAIL_AUTOCONFIRM=true&lt;/code&gt; only on a test stack.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why do auth emails redirect to localhost:3000?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;SITE_URL&lt;/code&gt; defaults to &lt;code&gt;http://localhost:3000&lt;/code&gt;. Set it to your app's URL. J0 explained the other common cause in &lt;a href="https://github.com/supabase/supabase/issues/22242" rel="noopener noreferrer"&gt;GitHub issue #22242&lt;/a&gt;: "A redirect to the &lt;code&gt;SITE_URL&lt;/code&gt; instead of the &lt;code&gt;redirect_to&lt;/code&gt; URL typically indicates that the redirect URL is not in the allowed URL list." Add the callback URL to &lt;code&gt;ADDITIONAL_REDIRECT_URLS&lt;/code&gt; and recreate Auth.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>supabase</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Portainer CE on Docker: Install, Stacks and Git Updates</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:01:46 +0000</pubDate>
      <link>https://dev.to/deploylark/portainer-ce-on-docker-install-stacks-and-git-updates-2gck</link>
      <guid>https://dev.to/deploylark/portainer-ce-on-docker-install-stacks-and-git-updates-2gck</guid>
      <description>&lt;p&gt;Portainer is a web UI for Docker. It shows containers, images, volumes and networks, and it can deploy whole Compose projects as stacks, including straight from a Git repository. The &lt;a href="https://github.com/portainer/portainer" rel="noopener noreferrer"&gt;portainer/portainer repository&lt;/a&gt; had about 38,700 GitHub stars on October 21, 2026, and the &lt;code&gt;portainer/portainer-ce&lt;/code&gt; image had passed 1.5 billion pulls on Docker Hub. The current Community Edition (CE) release is 2.45.2 LTS, published on October 8, 2026.&lt;/p&gt;

&lt;p&gt;This guide installs Portainer CE with Docker Compose, gets past the five-minute admin setup timeout, moves an app that was started with plain &lt;code&gt;docker compose&lt;/code&gt; into Portainer without losing its data, and sets up a stack that redeploys itself when you push to Git. It ends with a backup and an upgrade from 2.45.1 to 2.45.2.&lt;/p&gt;

&lt;p&gt;TL;DR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;portainer/portainer-ce:2.45.2&lt;/code&gt; with the Docker socket and a &lt;code&gt;portainer_data&lt;/code&gt; volume, and publish only &lt;code&gt;127.0.0.1:9443&lt;/code&gt;. Port 8000 is only for Edge Agents.&lt;/li&gt;
&lt;li&gt;Create the admin user within 5 minutes of the first start, using the &lt;code&gt;setup_token=&lt;/code&gt; value from &lt;code&gt;docker logs portainer&lt;/code&gt;. If the timeout hits, run &lt;code&gt;docker restart portainer&lt;/code&gt; and use the new token it prints.&lt;/li&gt;
&lt;li&gt;Apps started with &lt;code&gt;docker compose&lt;/code&gt; show as Limited. Run &lt;code&gt;docker compose down&lt;/code&gt; without &lt;code&gt;-v&lt;/code&gt;, then deploy the same file as a Portainer stack with the same name, and the stack gets Total control while keeping its volumes.&lt;/li&gt;
&lt;li&gt;For Git stacks, turn on polling on the Git source, or turn on Create a Webhook on the stack. In CE 2.45 both redeployed the stack after a push.&lt;/li&gt;
&lt;li&gt;Before an upgrade, download a backup from Settings, then bump the image tag and recreate the container. Your stacks keep running.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Portainer CE with Docker Compose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The compose file and ports
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.portainer.io/start/install-ce/server/docker/linux" rel="noopener noreferrer"&gt;official Linux install page&lt;/a&gt; uses the &lt;code&gt;lts&lt;/code&gt; tag and publishes ports 9443 and 8000 on all interfaces. The version below pins the tag and binds the UI to localhost, so nothing is reachable from the network until you put a reverse proxy in front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portainer&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;portainer&lt;/span&gt;&lt;span class="pi"&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;portainer&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;portainer/portainer-ce:2.45.2&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;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;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;portainer_data:/data&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="s"&gt;127.0.0.1:9443:9443&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;portainer_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portainer_data&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;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portainer_network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it with &lt;code&gt;docker compose up -d&lt;/code&gt;. The ports work like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9443 serves the UI and API over HTTPS with a self-signed certificate that Portainer creates on first start. The generated certificate was valid for five years.&lt;/li&gt;
&lt;li&gt;9000 is plain HTTP. The container still listens on it, and &lt;code&gt;http://portainer:9000&lt;/code&gt; answered from another container on the same network, but the docs only add &lt;code&gt;-p 9000:9000&lt;/code&gt; "for legacy reasons". Do not publish it.&lt;/li&gt;
&lt;li&gt;8000 is the tunnel server for Edge Agents. The docs call it optional, so leave it out unless you manage remote Edge environments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Portainer team member jamescarppe answered the port question in &lt;a href="https://github.com/portainer/portainer/issues/10797" rel="noopener noreferrer"&gt;issue #10797&lt;/a&gt;: "Port &lt;code&gt;8000&lt;/code&gt; has never been used for the Portainer UI - &lt;code&gt;8000&lt;/code&gt; is used for the Edge Agent tunnel exclusively."&lt;/p&gt;

&lt;p&gt;The socket mount gives Portainer full control of Docker on the host. Treat the UI like root access. For remote access, put it behind HTTPS with a proxy such as &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; or keep it on a VPN. The &lt;a href="https://docs.portainer.io/advanced/reverse-proxy" rel="noopener noreferrer"&gt;reverse proxy docs&lt;/a&gt; warn that a proxy that takes HTTP from the browser but forwards to 9443 breaks login, because Portainer then sets Secure cookies that the browser drops. Forward the &lt;code&gt;Host&lt;/code&gt; header as the browser sent it, which in nginx means &lt;code&gt;$http_host&lt;/code&gt; rather than &lt;code&gt;$host&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  First login and the setup token
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://127.0.0.1:9443&lt;/code&gt; and accept the certificate warning. The setup page asks for a username (default &lt;code&gt;admin&lt;/code&gt;), a password of at least 12 characters and a setup token. Since versions 2.43 and 2.39.4, new instances require that token and print it to the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs portainer 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep &lt;/span&gt;setup_token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.portainer.io/faqs/installing/setup-token" rel="noopener noreferrer"&gt;setup token FAQ&lt;/a&gt; lists the flags to change this: &lt;code&gt;--setup-token &amp;lt;value&amp;gt;&lt;/code&gt; sets your own, &lt;code&gt;--admin-password-file&lt;/code&gt; pre-sets the admin password so no token is needed, and &lt;code&gt;--no-setup-token&lt;/code&gt; turns the check off, which the docs reserve for networks you fully control. After the admin user exists, Portainer asks about Edge Compute (Skip is fine for a single host) and then opens the environment wizard with your local Docker already connected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the admin setup timeout
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the timeout looks like
&lt;/h3&gt;

&lt;p&gt;A new Portainer instance gives you 5 minutes to create the admin user. The &lt;a href="https://docs.portainer.io/faqs/installing/your-portainer-instance-has-timed-out-for-security-purposes-error-fix" rel="noopener noreferrer"&gt;timeout FAQ&lt;/a&gt; explains it as protection against someone else claiming a fresh install. The 5 minutes count from container start. In the log below, the HTTPS server started at 02:11:47 UTC and the timeout line appeared at 02:16:47 UTC, five minutes later:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F02-admin-setup-timeout.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F02-admin-setup-timeout.webp" alt="Terminal capture from Portainer CE 2.45.1. docker logs -t portainer shows starting Portainer version 2.45.1 and starting HTTPS server on port 9443 at 02:11:47, then at 02:16:47 the line the Portainer instance timed out for security purposes, to re-enable your Portainer instance, you will need to restart Portainer. A POST to /api/users/admin/init returns the JSON message Administrator initialization timeout. After docker restart portainer, the log prints a different setup token, hidden here, and the admin form must be finished within 5 minutes of the restart." width="800" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The container does not exit. &lt;code&gt;docker ps&lt;/code&gt; still shows it as Up and &lt;code&gt;/api/status&lt;/code&gt; still answers, so it looks healthy from the outside. The browser shows a New Portainer installation card that says the instance timed out for security purposes, and the API refuses to create the admin with &lt;code&gt;Administrator initialization timeout&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restart and finish within five minutes
&lt;/h3&gt;

&lt;p&gt;Restart the container and the clock starts again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker restart portainer
docker logs portainer 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep &lt;/span&gt;setup_token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restart prints a new setup token, so copy it again instead of reusing the one from the first start. The docs also mention that in some cases you need &lt;code&gt;docker rm portainer&lt;/code&gt; and a fresh &lt;code&gt;docker run&lt;/code&gt; or &lt;code&gt;docker compose up -d&lt;/code&gt;. If the timeout shows up on an instance that already had an admin, the &lt;a href="https://docs.portainer.io/faqs/installing/your-portainer-instance-has-timed-out-for-security-purposes-error-fix" rel="noopener noreferrer"&gt;same FAQ&lt;/a&gt; points at the data volume: if &lt;code&gt;portainer_data&lt;/code&gt; is not mounted, Portainer thinks it is a new install.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move existing compose projects into Portainer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the stack shows Limited
&lt;/h3&gt;

&lt;p&gt;Portainer lists every Compose project it finds on the host, but it can only edit the ones it deployed. Here Uptime Kuma 2.5.6 was started with plain &lt;code&gt;docker compose up -d&lt;/code&gt; from its own folder, as in our &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma setup guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;uptime-kuma&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;louislam/uptime-kuma:2.5.6&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kuma-data:/app/data&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="s"&gt;127.0.0.1:3001:3001&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;kuma-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Stacks list it appeared as &lt;code&gt;uptime-kuma&lt;/code&gt; with Control set to Limited, and its details page said so:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F03-limited-stack-created-outside.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F03-limited-stack-created-outside.webp" alt="Portainer CE 2.45.1 stack details page for uptime-kuma. An information box says This stack was created outside of Portainer. Control over this stack is limited. There is no Editor tab and no stop or delete stack button. The containers table lists uptime-kuma-uptime-kuma-1, healthy, image louislam/uptime-kuma:2.5.6, published port 3001." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Limited stack has no Editor tab, no stack-level stop or delete, no environment variables and no duplicate or migrate option. You can still start, stop, restart and inspect its containers. Portainer cannot fix that by itself because it never had the compose file. jamescarppe explained it in &lt;a href="https://github.com/portainer/portainer-docs/issues/75" rel="noopener noreferrer"&gt;portainer-docs issue #75&lt;/a&gt;: "The \"limited\" state displays when a stack is deployed outside of Portainer." The same thread notes that Portainer itself will always show as Limited, since it cannot deploy itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Take full control without losing data
&lt;/h3&gt;

&lt;p&gt;The fix is to redeploy the same compose file through Portainer, under the same name. Compose prefixes named volumes with the project name, and Portainer uses the stack name as the project name. So a stack called &lt;code&gt;uptime-kuma&lt;/code&gt; finds the existing &lt;code&gt;uptime-kuma_kuma-data&lt;/code&gt; volume.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the old project folder, run &lt;code&gt;docker compose down&lt;/code&gt;. Do not add &lt;code&gt;-v&lt;/code&gt;, which deletes the named volumes.&lt;/li&gt;
&lt;li&gt;In Portainer, open Stacks, then Add stack, and set Name to exactly the old project name (&lt;code&gt;uptime-kuma&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Choose Web editor or Upload and use the same compose file. Load the old &lt;code&gt;.env&lt;/code&gt; with Load variables from .env file if the project had one.&lt;/li&gt;
&lt;li&gt;Click Deploy the stack.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After the redeploy, the stack showed Total control, an Editor tab and stop and delete buttons. Docker listed the same &lt;code&gt;uptime-kuma_kuma-data&lt;/code&gt; volume, and Uptime Kuma opened on its login page instead of the first-run setup, so the admin account and data had survived. The same steps work for any compose project that keeps its data in named volumes, as long as the stack name matches the old project name.&lt;/p&gt;

&lt;p&gt;Relative bind mounts do not carry over. Portainer stores each stack's compose file under &lt;code&gt;/data/compose/&amp;lt;stack id&amp;gt;&lt;/code&gt; inside its own container, and Compose resolves &lt;code&gt;./data&lt;/code&gt; against that folder. A stack with &lt;code&gt;./data:/d&lt;/code&gt; got the host path &lt;code&gt;/data/compose/3/data&lt;/code&gt; as its mount source, a new empty directory, not the old project folder. A project like the &lt;a href="https://deploylark.com/posts/stirling-pdf-docker/" rel="noopener noreferrer"&gt;Stirling-PDF stack&lt;/a&gt;, which keeps everything under &lt;code&gt;./stirling-data&lt;/code&gt;, needs absolute host paths in its compose file before you redeploy it in Portainer. Relative paths for Git stacks were requested in &lt;a href="https://github.com/portainer/portainer/issues/6390" rel="noopener noreferrer"&gt;issue #6390&lt;/a&gt; and shipped in 2.17 as a Business Edition option, and the &lt;a href="https://docs.portainer.io/user/docker/stacks/add" rel="noopener noreferrer"&gt;Add a new stack docs&lt;/a&gt; still list relative path volumes as Business Edition only.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F01-portainer-migrate-and-gitops-flow.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F01-portainer-migrate-and-gitops-flow.webp" alt="Diagram in two parts. Part 1, move a plain compose project into Portainer: the Stacks list shows uptime-kuma with Control Limited and no Editor tab, then docker compose down runs in the old folder without -v so the volume stays, then Add stack in Portainer with name uptime-kuma and the same compose file, ending at Control Total with the same volume uptime-kuma_kuma-data reused and the admin login kept. Part 2, keep a stack updated from Git: a git push to main on Forgejo 17.0.0 reaches Portainer either through polling, a source setting with a 1 minute fetch interval that applied the change in 36 seconds, or a webhook, a stack setting where a POST to the stack URL applied it in 1.5 seconds. Both end in a redeploy where Portainer pulls the commit and runs docker compose up for the stack whoami. A note says the Re-pull image and Force redeployment options carry a Business Feature badge in CE and that Portainer itself stays Limited." width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy stacks from Git and keep them updated
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Add a Git source and deploy the stack
&lt;/h3&gt;

&lt;p&gt;In 2.45, Git stacks start from a source. Open Sources in the left menu, click Add new, and enter a name and the repository URL. Turn off Authentication for a public repository, or add a username and token for a private one. Turn on Enable polling and set a Fetch interval such as &lt;code&gt;5m&lt;/code&gt;. The form tests the connection before you can continue. Portainer clones the repository from inside its own container, so use a URL the container can reach. Here a Forgejo 17.0.0 server on the same Docker network served &lt;code&gt;http://forgejo:3000/labadmin/whoami-stack.git&lt;/code&gt; and the form reported Connection successful.&lt;/p&gt;

&lt;p&gt;The repository held one small compose file:&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;whoami&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;traefik/whoami:v1.12.0&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--name ${APP_NAME:-whoami}&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="s"&gt;127.0.0.1:8082:80&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;Then go to Stacks, Add stack, pick Repository, select the source, keep &lt;code&gt;refs/heads/main&lt;/code&gt; as the reference and set Compose path to &lt;code&gt;compose.yml&lt;/code&gt;. Environment variables can be set in the form, but a &lt;code&gt;stack.env&lt;/code&gt; file referenced from the compose file must already be in the repository, as the form itself points out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F04-git-stack-webhook-ce.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F04-git-stack-webhook-ce.webp" alt="Portainer CE 2.45.1 Create stack form with the Repository build method. Source is set to whoami-stack, Repository reference is refs/heads/main and Compose path is compose.yml. The Create a Webhook toggle is on and the Webhook URL is hidden in this screenshot. A warning says local changes will be overwritten by the git repository content. The Re-pull image and Force redeployment toggles are off and each carries a Business Feature badge." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the deploy, the stack page shows a Managed by Git panel with the repository, reference, file, current commit, polling interval and webhook URL, plus Pull and redeploy, Edit stack settings and Detach from Git buttons. Detaching is one way: the stack keeps only its main compose file and cannot go back to Git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Polling and webhooks in CE
&lt;/h3&gt;

&lt;p&gt;Both update paths worked in CE 2.45.1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Polling: with a 1-minute fetch interval on the source, a commit that changed the image from &lt;code&gt;traefik/whoami:v1.11.0&lt;/code&gt; to &lt;code&gt;v1.12.0&lt;/code&gt; was running 36 seconds after the push. The delay can be up to one full interval plus the redeploy. In 2.45 the interval belongs to the source, not the stack.&lt;/li&gt;
&lt;li&gt;Webhook: with Create a Webhook on, a commit followed by an unauthenticated &lt;code&gt;curl -X POST&lt;/code&gt; to the webhook URL returned HTTP 204, and the container served the new name 1.5 seconds later. A made-up webhook ID returned 404.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The webhook URL works without a login, so treat it like a password and only paste it into your Git server or CI secrets. Two options next to it carry a Business Feature badge in CE: Re-pull image, which pulls fresh images on every update, and Force redeployment, which overwrites local changes even when Git has not changed.&lt;/p&gt;

&lt;p&gt;Portainer's own pages disagree about what CE gets. The &lt;a href="https://www.portainer.io/features" rel="noopener noreferrer"&gt;features page&lt;/a&gt; lists "GitOps automatic updates and webhooks" among Business Edition features, and the January 2026 &lt;a href="https://www.portainer.io/blog/portainer-community-edition-ce-vs-portainer-business-edition-be-which-should-you-install" rel="noopener noreferrer"&gt;CE vs BE post&lt;/a&gt; sums up CE GitOps as "Basic Git deployment". The &lt;a href="https://docs.portainer.io/user/docker/stacks/add" rel="noopener noreferrer"&gt;Add a new stack docs&lt;/a&gt; describe polling and webhook updates for Git stacks with no Business Edition flag, and both worked in CE 2.45. The separate &lt;a href="https://docs.portainer.io/user/docker/stacks/webhooks" rel="noopener noreferrer"&gt;stack webhooks page&lt;/a&gt;, which the web editor and upload options link to, is marked Business Edition only. Since Portainer markets these as Business Edition features, check the toggles after each upgrade before you build a deploy pipeline on them.&lt;/p&gt;

&lt;p&gt;To get a new image in CE, change the tag in Git instead of reusing a moving tag like &lt;code&gt;latest&lt;/code&gt;. Pinned tags are the safer habit for any stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back up, upgrade and keep it private
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Back up the configuration
&lt;/h3&gt;

&lt;p&gt;In Settings, under Back up Portainer, Download backup file is available in CE. Store in S3 carries a Business Feature badge, and the &lt;a href="https://docs.portainer.io/admin/settings/general" rel="noopener noreferrer"&gt;settings docs&lt;/a&gt; list scheduled local backups as Business Edition too. The download here was a 38 KB &lt;code&gt;tar.gz&lt;/code&gt; with &lt;code&gt;portainer.db&lt;/code&gt;, the compose files of both Portainer stacks, the TLS certificate and key and the Edge tunnel private key. Turn on Password protect before you download it, and store it like a secret.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.portainer.io/faqs/getting-started/what-does-portainers-backup-include" rel="noopener noreferrer"&gt;backup FAQ&lt;/a&gt; is clear that this covers Portainer configuration only, not containers, images, volumes or the data inside them. Back up app data separately. For a full copy of Portainer itself, stop it and archive the volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; portainer_data:/data:ro &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/backup:/backup"&lt;/span&gt; alpine:3.24.2 &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/portainer_data-2.45.1.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Upgrade to a new version
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.portainer.io/start/upgrade/docker" rel="noopener noreferrer"&gt;Docker upgrade docs&lt;/a&gt; stop and remove the container, pull the new image and start it again with the same volume. With Compose, change the tag and recreate. This install went from 2.45.1 to the 2.45.2 tag shown in the compose file above:&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;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/portainer-ce:2.45.1/portainer-ce:2.45.2/"&lt;/span&gt; compose.yml
docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On start, 2.45.2 copied the database to &lt;code&gt;/data/backups/portainer.db.bak&lt;/code&gt;, logged &lt;code&gt;migrating database from version 2.45.1 to 2.45.2&lt;/code&gt; and then &lt;code&gt;db migrated to 2.45.2&lt;/code&gt;. The Uptime Kuma and whoami containers kept running the whole time, both stacks still showed Total control, and the next Git push was live within 46 seconds through polling. The &lt;a href="https://docs.portainer.io/faqs/upgrading/how-can-i-roll-back-to-a-previous-version-of-portainer" rel="noopener noreferrer"&gt;rollback FAQ&lt;/a&gt; explains why the database copy matters: a newer database will not start on an older Portainer, so a rollback means restoring &lt;code&gt;portainer.db.bak&lt;/code&gt; and starting the old image tag.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F05-stacks-total-after-upgrade.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdeploylark.com%2Fimages%2Fportainer-ce-install-stacks%2F05-stacks-total-after-upgrade.webp" alt="Portainer Community Edition 2.45.2 LTS Stacks list after the upgrade. gitsrv and portainer, both started with docker compose, show Control Limited. uptime-kuma, created 2026-10-21 10:18:59 by admin, and whoami, created 10:23:58 by admin and updated 10:29:20, show Control Total." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Portainer itself is light. At idle on 2.45.1 it used 27 to 34 MiB of RAM and close to 0% CPU. To show it on a start page, &lt;a href="https://deploylark.com/posts/gethomepage-docker-config/" rel="noopener noreferrer"&gt;Homepage&lt;/a&gt; has a Portainer widget. Keep the UI on localhost or a private network, and only expose it through HTTPS with access limited to people who should have root on the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Setup and access
&lt;/h3&gt;

&lt;h4&gt;
  
  
  What does "Your Portainer instance has timed out for security purposes" mean?
&lt;/h4&gt;

&lt;p&gt;You did not create the admin user within 5 minutes of the first start. Restart the container with &lt;code&gt;docker restart portainer&lt;/code&gt;, get the new setup token from &lt;code&gt;docker logs portainer&lt;/code&gt;, and finish the form within 5 minutes. Users reported the same log line in &lt;a href="https://github.com/portainer/portainer/issues/8457" rel="noopener noreferrer"&gt;issue #8457&lt;/a&gt;, and the &lt;a href="https://docs.portainer.io/faqs/installing/your-portainer-instance-has-timed-out-for-security-purposes-error-fix" rel="noopener noreferrer"&gt;official FAQ&lt;/a&gt; gives the restart fix.&lt;/p&gt;

&lt;h4&gt;
  
  
  Do you need ports 8000 and 9000?
&lt;/h4&gt;

&lt;p&gt;No. The UI and API run on 9443 over HTTPS. Port 8000 is only for Edge Agents and port 9000 is plain HTTP that the docs keep for legacy setups. A user in &lt;a href="https://github.com/portainer/portainer/issues/10797" rel="noopener noreferrer"&gt;issue #10797&lt;/a&gt; lost access on 9000 after an upgrade because the new default command no longer publishes it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where is the setup token?
&lt;/h4&gt;

&lt;p&gt;In the container log, on the line that starts with &lt;code&gt;setup_token=&lt;/code&gt;. Run &lt;code&gt;docker logs portainer 2&amp;gt;&amp;amp;1 | grep setup_token&lt;/code&gt;. The &lt;a href="https://docs.portainer.io/faqs/installing/setup-token" rel="noopener noreferrer"&gt;setup token FAQ&lt;/a&gt; covers the flags that set or skip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stacks and Git
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why do stacks show as Limited, and how do you fix it?
&lt;/h4&gt;

&lt;p&gt;Portainer did not deploy them, so it has no compose file to edit. Run &lt;code&gt;docker compose down&lt;/code&gt; without &lt;code&gt;-v&lt;/code&gt; in the project folder and deploy the same file as a Portainer stack with the same name. A user in &lt;a href="https://github.com/portainer/portainer/issues/12348" rel="noopener noreferrer"&gt;issue #12348&lt;/a&gt; could not delete or edit Limited stacks after reinstalling Portainer and fixed it by redeploying every compose file.&lt;/p&gt;

&lt;h4&gt;
  
  
  Are Git webhooks and automatic updates free in Portainer CE?
&lt;/h4&gt;

&lt;p&gt;For Git stacks in CE 2.45, yes: polling on the Git source and the Create a Webhook toggle both redeployed the stack after a push, and polling kept working after the upgrade to 2.45.2. Re-pull image and Force redeployment carry a Business Feature badge, and the docs mark webhooks on non-Git stacks as Business Edition. Portainer's features page still lists GitOps automatic updates and webhooks as Business Edition, so check the toggles in your version before you rely on them.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does a Portainer backup include my containers and volumes?
&lt;/h4&gt;

&lt;p&gt;No. The &lt;a href="https://docs.portainer.io/faqs/getting-started/what-does-portainers-backup-include" rel="noopener noreferrer"&gt;backup FAQ&lt;/a&gt; says it covers Portainer settings, users, environments, stack definitions and credentials, not containers, images, volumes or app data. Back up app volumes on their own.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>devops</category>
      <category>portainer</category>
    </item>
    <item>
      <title>Homepage Dashboard Docker Setup: Config, Widgets and Labels</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Tue, 20 Oct 2026 06:48:35 +0000</pubDate>
      <link>https://dev.to/deploylark/homepage-dashboard-docker-setup-config-widgets-and-labels-1p6e</link>
      <guid>https://dev.to/deploylark/homepage-dashboard-docker-setup-config-widgets-and-labels-1p6e</guid>
      <description>&lt;p&gt;Homepage is a static start page for a homelab. You describe services, bookmarks and widgets in YAML files, and it shows live data from the apps behind them, such as container stats, uptime and queue counts. On October 20, 2026, the &lt;a href="https://github.com/gethomepage/homepage" rel="noopener noreferrer"&gt;gethomepage/homepage repository&lt;/a&gt; had about 33,200 GitHub stars, and v2.6.0 had shipped three days earlier. This guide runs v2.6.0 with Docker Compose, wires up an Uptime Kuma widget, discovers a container from Docker labels through a read-only socket proxy and fixes the &lt;code&gt;Host validation failed&lt;/code&gt; error that trips up most first installs.&lt;/p&gt;

&lt;p&gt;TL;DR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;ghcr.io/gethomepage/homepage:v2.6.0&lt;/code&gt; with &lt;code&gt;./config&lt;/code&gt; mounted at &lt;code&gt;/app/config&lt;/code&gt; and port 3000 bound to &lt;code&gt;127.0.0.1&lt;/code&gt;. The first start writes default YAML files into that folder.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;HOMEPAGE_ALLOWED_HOSTS&lt;/code&gt; to every host and port people or monitors use, comma-separated with no spaces. Only &lt;code&gt;localhost:3000&lt;/code&gt; and &lt;code&gt;127.0.0.1:3000&lt;/code&gt; work without it.&lt;/li&gt;
&lt;li&gt;After any env change, recreate the container with &lt;code&gt;docker compose up -d&lt;/code&gt;. A &lt;code&gt;docker compose restart&lt;/code&gt; keeps the old value.&lt;/li&gt;
&lt;li&gt;For Docker stats and label discovery, point &lt;code&gt;docker.yaml&lt;/code&gt; at a &lt;code&gt;tecnativa/docker-socket-proxy&lt;/code&gt; container with &lt;code&gt;CONTAINERS=1&lt;/code&gt; and &lt;code&gt;POST=0&lt;/code&gt; instead of mounting the socket into Homepage.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;homepage.*&lt;/code&gt; labels to any container and it shows up on the dashboard, widget included.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Homepage with Docker Compose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The compose file
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://gethomepage.dev/installation/docker/" rel="noopener noreferrer"&gt;Docker install docs&lt;/a&gt; show a single &lt;code&gt;homepage&lt;/code&gt; service with an optional socket mount. This file adds a socket proxy, as the &lt;a href="https://gethomepage.dev/configs/docker/" rel="noopener noreferrer"&gt;Docker integration docs&lt;/a&gt; suggest, plus two services for Homepage to watch: Uptime Kuma 2.5.6 and the tiny &lt;code&gt;traefik/whoami&lt;/code&gt; web server. The official examples use &lt;code&gt;:latest&lt;/code&gt;. Every tag here is pinned to what ran:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9kojke4v5nlln9uaf7wk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9kojke4v5nlln9uaf7wk.webp" alt="Diagram of the stack on the Compose network hp_default. A browser sends requests with Host homepage.lab.test:3000 to the homepage container, v2.6.0 on 127.0.0.1:3000, where the host is checked against HOMEPAGE_ALLOWED_HOSTS. Homepage reads labels, status and stats from dockerproxy, socket-proxy v0.5.0 with CONTAINERS=1 and POST=0, which mounts docker.sock read-only. Homepage also calls uptime-kuma 2.5.6 for the uptimekuma widget and whoami v1.12.0 for a customapi widget. Uptime Kuma polls homepage:3000/api/healthcheck, so homepage:3000 is an allowed host too." width="800" height="392"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hp&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;dockerproxy&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;tecnativa/docker-socket-proxy:v0.5.0&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;CONTAINERS=1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POST=0&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;/var/run/docker.sock:/var/run/docker.sock:ro&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;homepage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/gethomepage/homepage:v2.6.0&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&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;./config:/app/config&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;HOMEPAGE_ALLOWED_HOSTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${HOMEPAGE_ALLOWED_HOSTS:-}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dockerproxy&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;uptime-kuma&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;louislam/uptime-kuma:2.5.6&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3001:3001"&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;kuma-data:/app/data&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;whoami&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;traefik/whoami:v1.12.0&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8081:80"&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.group=Lab&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.name=whoami&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.icon=traefik.png&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.href=http://127.0.0.1:8081/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.description=Discovered from Docker labels&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.type=customapi&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.url=http://whoami/api&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[0].label=Hostname&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[0].field=hostname&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[1].label=Remote&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[1].field=remoteAddr&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;kuma-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the allowed hosts in a &lt;code&gt;.env&lt;/code&gt; file next to it. The section on host validation below explains each entry:&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="nv"&gt;HOMEPAGE_ALLOWED_HOSTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homepage.lab.test:3000,homepage:3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; config
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:3000/api/healthcheck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The health check answered 2.9 seconds after &lt;code&gt;docker compose up -d&lt;/code&gt;. That curl works without an allowed-hosts entry only because the host port is 3000. When I published Homepage on &lt;code&gt;127.0.0.1:3900&lt;/code&gt; instead, the same health check on the server returned 400 until &lt;code&gt;127.0.0.1:3900&lt;/code&gt; was on the list. By default the container runs as root. The docs support &lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt; for a non-root user, and with the socket proxy Homepage does not need socket access at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  First start and the default config files
&lt;/h3&gt;

&lt;p&gt;Start with an empty &lt;code&gt;./config&lt;/code&gt; folder. After the first start and a few page loads, mine held &lt;code&gt;settings.yaml&lt;/code&gt;, &lt;code&gt;services.yaml&lt;/code&gt;, &lt;code&gt;widgets.yaml&lt;/code&gt;, &lt;code&gt;bookmarks.yaml&lt;/code&gt;, &lt;code&gt;docker.yaml&lt;/code&gt;, &lt;code&gt;kubernetes.yaml&lt;/code&gt; and &lt;code&gt;proxmox.yaml&lt;/code&gt;, plus empty &lt;code&gt;custom.css&lt;/code&gt; and &lt;code&gt;custom.js&lt;/code&gt; files and a &lt;code&gt;logs&lt;/code&gt; folder. Most of the YAML files start with a comment that links to their docs page. The default &lt;code&gt;services.yaml&lt;/code&gt; holds three placeholder groups pointing at &lt;code&gt;http://localhost/&lt;/code&gt;, the default &lt;code&gt;widgets.yaml&lt;/code&gt; shows CPU, memory and disk usage plus a DuckDuckGo search box, and the default &lt;code&gt;settings.yaml&lt;/code&gt; only has placeholder weather API keys.&lt;/p&gt;

&lt;p&gt;Edits to these files do not need a container restart. The settings docs say to click the refresh icon at the bottom right of the page to clear cached dashboard data. Logs go to &lt;code&gt;config/logs/homepage.log&lt;/code&gt; as well as &lt;code&gt;docker compose logs homepage&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add services and info widgets
&lt;/h2&gt;

&lt;h3&gt;
  
  
  services.yaml with an Uptime Kuma widget
&lt;/h3&gt;

&lt;p&gt;A service entry is a card with a name, icon, link and description. Add &lt;code&gt;server&lt;/code&gt; and &lt;code&gt;container&lt;/code&gt; to show a status dot and container stats, and add a &lt;code&gt;widget&lt;/code&gt; block to pull data from the app API. The &lt;a href="https://gethomepage.dev/widgets/services/uptime-kuma/" rel="noopener noreferrer"&gt;Uptime Kuma widget&lt;/a&gt; reads a single status page, so create one in Uptime Kuma first. This one has the slug &lt;code&gt;lab&lt;/code&gt; and two HTTP monitors, one for whoami and one for the Homepage health check:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Uptime Kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uptime-kuma.png&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3001/&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Status page lab&lt;/span&gt;
        &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lab-docker&lt;/span&gt;
        &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hp-uptime-kuma-1&lt;/span&gt;
        &lt;span class="na"&gt;showStats&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;widget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uptimekuma&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://uptime-kuma:3001&lt;/span&gt;
          &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lab&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The widget URL uses the Compose service name, because the request comes from the Homepage container, not from the browser. Homepage looks up &lt;code&gt;icon&lt;/code&gt; names in the &lt;a href="https://github.com/homarr-labs/dashboard-icons" rel="noopener noreferrer"&gt;Dashboard Icons&lt;/a&gt; set, so you only type the name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnrrklgzms0f9jugve24o.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnrrklgzms0f9jugve24o.webp" alt="Homepage v2.6.0 dashboard in the dark slate theme. The top bar shows CPU at 3%, 8 GiB memory free, 116 GB disk free, the date Oct 20, 2026, 10:14 AM and a search box. The Monitoring group has an Uptime Kuma card with container stats, 1% CPU, 145 MB memory, 143 kB RX and 3.58 MB TX, and the Uptime Kuma widget showing 2 sites up, 0 sites down and 67% uptime. The Lab group has a whoami card, discovered from Docker labels, with a customapi widget showing the container hostname and the remote address 172.21.0.5:43146. A Docs bookmarks section lists Homepage docs and Uptime Kuma wiki. The footer reads v2.6.0 (51cc168, Oct 17, 2026)." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 67% is the average 24-hour uptime of every monitor on the status page, which is how the widget computes it. When the screenshot was taken, whoami stood at 100% and the Homepage monitor at 33%, because two of its three checks had failed with HTTP 400. The host validation section below explains why.&lt;/p&gt;

&lt;h3&gt;
  
  
  widgets.yaml and settings.yaml
&lt;/h3&gt;

&lt;p&gt;Info widgets sit in the bar across the top. This file keeps the resources and search widgets from the defaults and adds a clock:&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="nn"&gt;---&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;cpu&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;memory&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;disk&lt;/span&gt;&lt;span class="pi"&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;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;text_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;md&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medium&lt;/span&gt;
      &lt;span class="na"&gt;timeStyle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;short&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;duckduckgo&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;_blank&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Theming and layout live in &lt;code&gt;settings.yaml&lt;/code&gt;. The &lt;a href="https://gethomepage.dev/configs/settings/" rel="noopener noreferrer"&gt;settings docs&lt;/a&gt; cover the full list. These keys set a dark theme with the slate palette, boxed headers, status dots instead of text, and a two-column row layout for each group:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lab dashboard&lt;/span&gt;
&lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dark&lt;/span&gt;
&lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;slate&lt;/span&gt;
&lt;span class="na"&gt;headerStyle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boxed&lt;/span&gt;
&lt;span class="na"&gt;statusStyle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dot&lt;/span&gt;
&lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;row&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;Lab&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;row&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;background&lt;/code&gt; for a wallpaper image and &lt;code&gt;custom.css&lt;/code&gt; in the config folder for anything the settings do not cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker integration and label discovery
&lt;/h2&gt;

&lt;h3&gt;
  
  
  docker.yaml through a socket proxy
&lt;/h3&gt;

&lt;p&gt;Homepage talks to the Docker API for container status, CPU and memory stats, and label discovery. The docs offer two ways in. Mounting &lt;code&gt;/var/run/docker.sock&lt;/code&gt; into Homepage works, but the docs note that "In order to use the socket directly homepage must be running as root", and socket access gives full control of the host. The &lt;code&gt;:ro&lt;/code&gt; mount flag does not change that, because it doesn't limit the Docker API. The safer route is a socket proxy that only exposes read endpoints. Point &lt;code&gt;docker.yaml&lt;/code&gt; at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;lab-docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerproxy&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2375&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lab-docker&lt;/code&gt; is just a name. Service entries refer to it with &lt;code&gt;server: lab-docker&lt;/code&gt;. The proxy listens on port 2375 inside the Compose network only, with no published port. With &lt;code&gt;CONTAINERS=1&lt;/code&gt; and &lt;code&gt;POST=0&lt;/code&gt;, curl from another container on the same network got these results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET  /containers/json             200
POST /containers/&amp;lt;id&amp;gt;/restart     403
GET  /images/json                 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So Homepage can list containers and read stats, but it cannot restart anything or browse images. If you run Docker Swarm, the docs add &lt;code&gt;SERVICES=1&lt;/code&gt; and &lt;code&gt;TASKS=1&lt;/code&gt; to the proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Labels on the container
&lt;/h3&gt;

&lt;p&gt;Automatic discovery reads &lt;code&gt;homepage.*&lt;/code&gt; labels from every container the Docker server can see. The keys mirror the YAML in &lt;code&gt;services.yaml&lt;/code&gt;, written with dots. The whoami container in the compose file above carries a group, name, icon, link and description, plus a &lt;code&gt;customapi&lt;/code&gt; widget that calls the whoami JSON endpoint and maps two fields:&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;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.group=Lab&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.name=whoami&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.icon=traefik.png&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.href=http://127.0.0.1:8081/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.description=Discovered from Docker labels&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.type=customapi&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.url=http://whoami/api&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[0].label=Hostname&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[0].field=hostname&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[1].label=Remote&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homepage.widget.mappings[1].field=remoteAddr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about whoami is in &lt;code&gt;services.yaml&lt;/code&gt;. After &lt;code&gt;docker compose up -d&lt;/code&gt;, the Lab group appeared with the whoami card, a green status dot from the container state and the two mapped values, visible in the dashboard screenshot above. The Remote value is an address on the Compose network, because Homepage makes the widget request from its own container, not from the browser. The docs say the server and container values are inferred for discovered services, and the whoami card needed neither label. To keep one dashboard from picking up labels meant for another, the docs describe &lt;code&gt;instanceName&lt;/code&gt; in &lt;code&gt;settings.yaml&lt;/code&gt; with matching &lt;code&gt;homepage.instance.&amp;lt;name&amp;gt;.*&lt;/code&gt; labels.&lt;/p&gt;

&lt;p&gt;The same pattern works for real apps that have a native widget. A &lt;a href="https://deploylark.com/posts/paperless-ngx-docker-compose-ocr/" rel="noopener noreferrer"&gt;Paperless-ngx&lt;/a&gt; or &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; container can carry its own &lt;code&gt;homepage.widget.type&lt;/code&gt; and API credentials as labels, so the dashboard entry lives next to the service it describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix Host validation failed with HOMEPAGE_ALLOWED_HOSTS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the error looks like
&lt;/h3&gt;

&lt;p&gt;Since v1.0.0, released on March 14, 2025, Homepage checks the &lt;code&gt;Host&lt;/code&gt; header of every API request. The &lt;a href="https://gethomepage.dev/installation/" rel="noopener noreferrer"&gt;installation docs&lt;/a&gt; list it as the one required variable for reaching Homepage through any URL other than localhost. If the host is not on the list, the page and its API calls, &lt;code&gt;/api/healthcheck&lt;/code&gt; included, return HTTP 400. This is the response to &lt;code&gt;Host: homepage.lab.test:3000&lt;/code&gt; before the variable was set:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi9tqcflio46cxuwbjrl7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi9tqcflio46cxuwbjrl7.webp" alt="Terminal capture from the lab. A curl request with Host homepage.lab.test:3000 to 127.0.0.1:3000/api/services returns HTTP 400 with the JSON error Host validation failed. See logs for more details. The Homepage log shows error: Host validation failed for: homepage.lab.test:3000 and the hint to set the HOMEPAGE_ALLOWED_HOSTS environment variable. After setting HOMEPAGE_ALLOWED_HOSTS=homepage.lab.test:3000,homepage:3000 in .env and running docker compose up -d homepage, the same request returns 200 and a request with Host evil.example:3000 still returns 400." width="800" height="303"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The log line names the exact host and port that failed. Copy that value into the variable. Requests to &lt;code&gt;http://127.0.0.1:3000&lt;/code&gt; and &lt;code&gt;http://localhost:3000&lt;/code&gt; always pass, which is why a first test on the server itself often works and the same dashboard breaks from a laptop on the LAN. That only covers port 3000. If you map another host port, such as &lt;code&gt;8080:3000&lt;/code&gt;, add &lt;code&gt;127.0.0.1:8080&lt;/code&gt; or the LAN address with &lt;code&gt;:8080&lt;/code&gt;, as users found in &lt;a href="https://github.com/gethomepage/homepage/discussions/4920" rel="noopener noreferrer"&gt;discussion #4920&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A second case is easy to miss. Uptime Kuma checked &lt;code&gt;http://homepage:3000/api/healthcheck&lt;/code&gt; from inside the Compose network, so its requests arrived with &lt;code&gt;Host: homepage:3000&lt;/code&gt;. That monitor went Down at 10:12:15 with HTTP 400 and came back Up at 10:14:15, right after &lt;code&gt;homepage:3000&lt;/code&gt; joined the list. Any monitor, reverse proxy or script that reaches Homepage by container name needs that name on the list too, port included. Our &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma guide&lt;/a&gt; covers setting up that kind of HTTP monitor.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fraskty1kzrc6sn0c30gt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fraskty1kzrc6sn0c30gt.webp" alt="Uptime Kuma 2.5.6 detail page for the Homepage monitor, which checks http://homepage:3000/api/healthcheck every 60 seconds. The heartbeat bar shows two red beats followed by green ones and the status is Up. Uptime for 24 hours, 30 days and 1 year reads 66.67 percent. The event list shows Down at 2026-10-20 10:12:15 with Request failed with status code 400, then Up at 10:14:15 with 200 - OK." width="800" height="643"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This screenshot came a few minutes later than the dashboard one. By then four of the monitor's six checks had passed, so its own uptime read 66.67%.&lt;/p&gt;

&lt;h3&gt;
  
  
  The value to set, and recreate the container
&lt;/h3&gt;

&lt;p&gt;The value is a comma-separated list with no spaces. Include the port whenever the browser URL has one:&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="nv"&gt;HOMEPAGE_ALLOWED_HOSTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homepage.lab.test:3000,homepage:3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind a reverse proxy on port 443, the browser sends the bare domain, such as &lt;code&gt;dash.example.com&lt;/code&gt;, so that is the entry. With that list, &lt;code&gt;homepage.lab.test:3000&lt;/code&gt; and &lt;code&gt;homepage:3000&lt;/code&gt; returned 200, and &lt;code&gt;evil.example:3000&lt;/code&gt; still returned 400.&lt;/p&gt;

&lt;p&gt;Then recreate the container. I added a third host to &lt;code&gt;.env&lt;/code&gt; and ran &lt;code&gt;docker compose restart homepage&lt;/code&gt;. Inside the container, the variable still held the old two hosts and the new host got 400. After &lt;code&gt;docker compose up -d homepage&lt;/code&gt;, the variable held all three and the request returned 200. Maintainer shamoon wrote in &lt;a href="https://github.com/gethomepage/homepage/discussions/5033" rel="noopener noreferrer"&gt;discussion #5033&lt;/a&gt;: "You need to re-create a container when you change env variables, not just restart." Check what the container really sees with &lt;code&gt;docker compose exec homepage env | grep ALLOWED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Setting the variable to &lt;code&gt;*&lt;/code&gt; turns the check off. The docs do not recommend it and add that "the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections."&lt;/p&gt;

&lt;h2&gt;
  
  
  Secure and run Homepage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Put authentication in front of it
&lt;/h3&gt;

&lt;p&gt;Homepage shows internal hostnames and links, and its API proxy calls your services with the keys from your config. Keep it on &lt;code&gt;127.0.0.1&lt;/code&gt; or a private network and publish it through a reverse proxy with TLS, as in the &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt;. Since v2.0.0 Homepage also includes an optional login gate, which this guide doesn't turn on. The installation docs list &lt;code&gt;HOMEPAGE_AUTH_ENABLED=true&lt;/code&gt;, a &lt;code&gt;HOMEPAGE_AUTH_SECRET&lt;/code&gt; of at least 32 characters, &lt;code&gt;HOMEPAGE_EXTERNAL_URL&lt;/code&gt;, and either &lt;code&gt;HOMEPAGE_AUTH_PASSWORD&lt;/code&gt; or OIDC settings. The same docs warn that Homepage does not rate limit password attempts, so a proxy or VPN should still sit in front of any public instance. Keep to the newest release as well. v2.6.0 fixes &lt;a href="https://github.com/gethomepage/homepage/security/advisories/GHSA-qxcr-4rv3-v23r" rel="noopener noreferrer"&gt;GHSA-qxcr-4rv3-v23r&lt;/a&gt;, a high-severity bypass of that login gate in versions 2.0.0 to 2.5.0.&lt;/p&gt;

&lt;p&gt;API keys do not need to sit in plain YAML. Per the Docker install docs, the value of any &lt;code&gt;HOMEPAGE_VAR_NAME&lt;/code&gt; environment variable replaces &lt;code&gt;{{HOMEPAGE_VAR_NAME}}&lt;/code&gt; in the config, and &lt;code&gt;HOMEPAGE_FILE_NAME&lt;/code&gt; points at a file, such as a Docker secret, whose contents replace &lt;code&gt;{{HOMEPAGE_FILE_NAME}}&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource use
&lt;/h3&gt;

&lt;p&gt;Homepage is light. At idle, &lt;code&gt;docker stats&lt;/code&gt; showed these averages for the four containers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Container&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;homepage v2.6.0&lt;/td&gt;
&lt;td&gt;116.5 MiB&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uptime-kuma 2.5.6&lt;/td&gt;
&lt;td&gt;139.6 MiB&lt;/td&gt;
&lt;td&gt;0.41%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docker-socket-proxy v0.5.0&lt;/td&gt;
&lt;td&gt;22.3 MiB&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whoami v1.12.0&lt;/td&gt;
&lt;td&gt;2.4 MiB&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;280.8 MiB&lt;/td&gt;
&lt;td&gt;0.42%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Homepage never went above 0.04% CPU. It fits next to heavier apps on a small VPS, such as a &lt;a href="https://deploylark.com/posts/coolify-install-vps-first-app/" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; host or a box that also runs &lt;a href="https://deploylark.com/posts/plausible-community-edition-self-host/" rel="noopener noreferrer"&gt;Plausible&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Host validation and setup
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does the error stay after setting HOMEPAGE_ALLOWED_HOSTS?
&lt;/h4&gt;

&lt;p&gt;Usually the container was restarted, not recreated, so it still has the old environment. Run &lt;code&gt;docker compose up -d&lt;/code&gt; and check with &lt;code&gt;docker compose exec homepage env&lt;/code&gt;. In &lt;a href="https://github.com/gethomepage/homepage/discussions/5033" rel="noopener noreferrer"&gt;discussion #5033&lt;/a&gt; and &lt;a href="https://github.com/gethomepage/homepage/discussions/5412" rel="noopener noreferrer"&gt;discussion #5412&lt;/a&gt;, the maintainer's answer to this error was to recreate the container, not just restart it. The #5412 user ran Homepage through TrueNAS apps and then Dockge, so check how your stack manager recreates a container.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does HOMEPAGE_ALLOWED_HOSTS need the port?
&lt;/h4&gt;

&lt;p&gt;Yes, whenever the URL has one. The check compares the full &lt;code&gt;Host&lt;/code&gt; header, so &lt;code&gt;192.168.1.10:3000&lt;/code&gt; and &lt;code&gt;192.168.1.10&lt;/code&gt; are different entries. &lt;a href="https://github.com/gethomepage/homepage/discussions/4920" rel="noopener noreferrer"&gt;Discussion #4920&lt;/a&gt; also shows that a reverse proxy can change the Host header, so match the value printed in the log.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can the list contain spaces?
&lt;/h4&gt;

&lt;p&gt;No. The docs specify a comma-separated list with no spaces, and &lt;a href="https://github.com/gethomepage/homepage/discussions/4789" rel="noopener noreferrer"&gt;discussion #4789&lt;/a&gt; traces a failing setup to a space after a comma.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widgets and Docker
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does a widget show API Error?
&lt;/h4&gt;

&lt;p&gt;The widget call from Homepage to the service failed. The &lt;a href="https://gethomepage.dev/troubleshooting/" rel="noopener noreferrer"&gt;troubleshooting page&lt;/a&gt; suggests the API Error Information button on the widget, then &lt;code&gt;config/logs/homepage.log&lt;/code&gt;, and &lt;code&gt;LOG_LEVEL=debug&lt;/code&gt; for more detail. Check that the widget URL works from inside the Homepage container. A container name like &lt;code&gt;http://uptime-kuma:3001&lt;/code&gt; works there, &lt;code&gt;localhost&lt;/code&gt; does not.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why are containers with homepage labels not showing up?
&lt;/h4&gt;

&lt;p&gt;Homepage needs a working Docker connection in &lt;code&gt;docker.yaml&lt;/code&gt;, and the labels should include &lt;code&gt;homepage.group&lt;/code&gt; and &lt;code&gt;homepage.name&lt;/code&gt;, as in the docs examples. With the socket mounted directly, Homepage must run as root or have socket access, a common cause in &lt;a href="https://github.com/gethomepage/homepage/discussions/3124" rel="noopener noreferrer"&gt;discussion #3124&lt;/a&gt;. A socket proxy with &lt;code&gt;CONTAINERS=1&lt;/code&gt; avoids the permission problem.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is mounting docker.sock into Homepage safe?
&lt;/h4&gt;

&lt;p&gt;It works, but it hands the container full control of Docker. The Docker integration docs point to a socket proxy for that reason. With &lt;code&gt;POST=0&lt;/code&gt;, the proxy above refused a container restart with 403 while still serving the container list.&lt;/p&gt;

&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/gethomepage-docker-config/" rel="noopener noreferrer"&gt;Homepage Dashboard Docker Setup: Config, Widgets and Labels&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>homelab</category>
      <category>dashboard</category>
    </item>
    <item>
      <title>GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:33:43 +0000</pubDate>
      <link>https://dev.to/deploylark/gitlab-ce-vs-forgejo-self-hosted-git-on-docker-measured-2l2i</link>
      <guid>https://dev.to/deploylark/gitlab-ce-vs-forgejo-self-hosted-git-on-docker-measured-2l2i</guid>
      <description>&lt;p&gt;GitLab CE and Forgejo both give you a private Git server with issues, code review, CI and a container registry. They sit at opposite ends of the weight scale. This comparison ran both under Docker Compose on the same box, pushed a real pipeline through each runner, pushed an image to each registry, migrated the same GitHub repository into both and ran each backup command. The numbers below come from that run on October 19, 2026.&lt;/p&gt;

&lt;p&gt;TL;DR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On an 8 vCPU host, Forgejo 15.0.10 with Postgres and a runner idled at 285.7 MiB of RAM and under 1% of one CPU core. GitLab CE 19.5.0 with its runner idled at 8,722.6 MiB with default settings. The GitLab container alone dropped to 2,466.5 MiB with the memory-constrained settings from the GitLab docs.&lt;/li&gt;
&lt;li&gt;Forgejo answered its health check 5.4 seconds after &lt;code&gt;docker compose up -d&lt;/code&gt;. GitLab served its sign-in page after 145 seconds.&lt;/li&gt;
&lt;li&gt;Both ran a trivial CI job, accepted a &lt;code&gt;docker push&lt;/code&gt; to the built-in registry, migrated a GitHub repo and wrote a backup archive of 15.6 MB (Forgejo) or 16.2 MB (GitLab).&lt;/li&gt;
&lt;li&gt;GitLab CE is MIT licensed, with paid features under a separate EE license. Forgejo is GPL v3 or later. Forgejo federation is experimental and off by default.&lt;/li&gt;
&lt;li&gt;Pick Forgejo for a small team or a homelab VPS. Pick GitLab CE when you want its integrated CI/CD and project management and can give it 8 GB or more of RAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;h2&gt;
  
  
  The measured numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Idle RAM and CPU
&lt;/h3&gt;

&lt;p&gt;Each stack ran alone on the same 8 vCPU host. The numbers are idle averages from &lt;code&gt;docker stats&lt;/code&gt;, taken after each stack had run a pipeline, a registry push, a migration and a backup. 100% CPU means one full core.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12sgg4tfam59ohxzf2ir.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12sgg4tfam59ohxzf2ir.webp" alt="Bar chart of idle memory on the same 8 vCPU host. Forgejo 15.0.10 stack with server, Postgres and runner: 285.7 MiB. GitLab CE 19.5.0 with memory-constrained settings, GitLab container only: 2,466.5 MiB. GitLab CE 19.5.0 with default settings plus runner: 8,722.6 MiB." width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;Avg RAM&lt;/th&gt;
&lt;th&gt;Peak RAM&lt;/th&gt;
&lt;th&gt;Avg CPU&lt;/th&gt;
&lt;th&gt;Peak CPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Forgejo 15.0.10 + Postgres 17.11 + runner 13.2.0&lt;/td&gt;
&lt;td&gt;285.7 MiB&lt;/td&gt;
&lt;td&gt;300.1 MiB&lt;/td&gt;
&lt;td&gt;0.57%&lt;/td&gt;
&lt;td&gt;0.89%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitLab CE 19.5.0 default + runner 19.5.0&lt;/td&gt;
&lt;td&gt;8,722.6 MiB&lt;/td&gt;
&lt;td&gt;8,797.6 MiB&lt;/td&gt;
&lt;td&gt;16.50%&lt;/td&gt;
&lt;td&gt;87.05%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitLab CE 19.5.0 memory-constrained (container only)&lt;/td&gt;
&lt;td&gt;2,466.5 MiB&lt;/td&gt;
&lt;td&gt;2,537.5 MiB&lt;/td&gt;
&lt;td&gt;9.29%&lt;/td&gt;
&lt;td&gt;74.12%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The constrained row leaves out the runner, so add about 65 MiB to compare it with the others. GitLab's default memory also depends on the server. Per the &lt;a href="https://docs.gitlab.com/administration/operations/puma/" rel="noopener noreferrer"&gt;GitLab Puma docs&lt;/a&gt;, it starts one Puma worker per CPU, limited by RAM at about 1 GB each, so a 2 vCPU server starts fewer workers than this 8 vCPU host did.&lt;/p&gt;

&lt;p&gt;Inside Forgejo, the server process took about 204 MiB, Postgres about 73 MiB and the idle runner about 8 MiB. GitLab is one container with many services. &lt;code&gt;gitlab-ctl status&lt;/code&gt; listed 16 of them, including Puma, Sidekiq, Gitaly, PostgreSQL, Redis, Prometheus, Alertmanager, the registry and several exporters. With default settings, &lt;code&gt;ps&lt;/code&gt; inside the container showed ten Ruby processes at roughly 1 GB of resident memory each, and the cgroup reported 8,022 MiB of anonymous memory. The GitLab runner added about 65 MiB.&lt;/p&gt;

&lt;p&gt;The default GitLab number is in line with what people report on the GitLab forum. In a 2018 thread, one user who had just installed the &lt;code&gt;gitlab-ce&lt;/code&gt; container with a single account and no code saw &lt;a href="https://forum.gitlab.com/t/high-ram-usage/17546" rel="noopener noreferrer"&gt;8.452 GB in docker stats&lt;/a&gt;. The &lt;a href="https://docs.gitlab.com/install/requirements/" rel="noopener noreferrer"&gt;installation requirements&lt;/a&gt; set 16 GB and 8 vCPU as the single-node baseline, and at least 8 GB for memory-constrained setups.&lt;/p&gt;

&lt;p&gt;The memory-constrained profile comes from the &lt;a href="https://docs.gitlab.com/omnibus/settings/memory_constrained_envs/" rel="noopener noreferrer"&gt;GitLab docs page for small environments&lt;/a&gt;, which states that "With some adjustments, GitLab can run comfortably on much lower specifications than described in minimum requirements or the reference architectures." The lab applied the page's Puma, Sidekiq, Prometheus and jemalloc settings, plus the Gitaly spawn limit, but not the Gitaly per-repo concurrency block. On this host that cut the GitLab container's idle RAM by about 72%, and a pipeline still ran green. The same page asks for at least 2 GB of RAM plus 1 GB of swap and warns that functionality and performance can degrade.&lt;/p&gt;

&lt;p&gt;Forgejo 17.0.0, released October 15, 2026, idled at 315.3 MiB on the same host. As of October 19, 2026, the &lt;a href="https://codeberg.org/forgejo/forgejo/releases/tag/v17.0.0" rel="noopener noreferrer"&gt;17.0.0 release page&lt;/a&gt; advises against upgrading because of an issue that "prevents pushing, merging, or other code operations in some cases," so the rest of this guide uses the 15.0 LTS line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install time and steps
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Forgejo 15.0.10&lt;/th&gt;
&lt;th&gt;GitLab CE 19.5.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td&gt;forgejo 188 MB, postgres 297 MB, runner 46 MB&lt;/td&gt;
&lt;td&gt;gitlab-ce 4.07 GB, gitlab-runner 245 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull time on this host&lt;/td&gt;
&lt;td&gt;3 to 7 s per image&lt;/td&gt;
&lt;td&gt;53 s for gitlab-ce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;up -d&lt;/code&gt; to ready&lt;/td&gt;
&lt;td&gt;5.4 s (&lt;code&gt;/api/healthz&lt;/code&gt; passes)&lt;/td&gt;
&lt;td&gt;145 s (&lt;code&gt;/users/sign_in&lt;/code&gt; returns 200)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin account&lt;/td&gt;
&lt;td&gt;&lt;code&gt;forgejo admin user create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GITLAB_ROOT_PASSWORD&lt;/code&gt; on first boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runner setup&lt;/td&gt;
&lt;td&gt;generate token, register, restart runner&lt;/td&gt;
&lt;td&gt;create runner in UI or API, register&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Image sizes are the local uncompressed sizes from &lt;code&gt;docker images&lt;/code&gt;. GitLab also pulled its helper image, &lt;code&gt;gitlab-runner-helper:x86_64-v19.5.0&lt;/code&gt; (112 MB), on the first job. Switching GitLab to the constrained settings and recreating the container took another 98 seconds before the sign-in page came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install both with Docker Compose
&lt;/h2&gt;

&lt;p&gt;Both stacks below publish ports on &lt;code&gt;127.0.0.1&lt;/code&gt; only. Put a TLS proxy such as &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; in front before anyone else uses them, the same pattern as the &lt;a href="https://deploylark.com/posts/plausible-community-edition-self-host/" rel="noopener noreferrer"&gt;Plausible CE guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forgejo with Postgres and a runner
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://forgejo.org/docs/latest/admin/installation/docker/" rel="noopener noreferrer"&gt;Forgejo Docker docs&lt;/a&gt; start from a single &lt;code&gt;server&lt;/code&gt; service and add Postgres as an option. The file below adds Postgres, skips the web installer with &lt;code&gt;INSTALL_LOCK&lt;/code&gt;, and adds a runner that waits until it has been registered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fj&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;server&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;codeberg.org/forgejo/forgejo:15.0.10&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;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__database__DB_TYPE=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__database__HOST=db:5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__database__NAME=forgejo&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__database__USER=forgejo&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__database__PASSWD=change-me-db-password&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__security__INSTALL_LOCK=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__ROOT_URL=http://127.0.0.1:3000/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__SSH_PORT=2222&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__actions__ENABLED=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;fj-data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:2222:22"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17.11-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="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;POSTGRES_USER=forgejo&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=change-me-db-password&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=forgejo&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;fj-pg:/var/lib/postgresql/data&lt;/span&gt;
  &lt;span class="na"&gt;runner&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;code.forgejo.org/forgejo/runner:13.2.0&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0:0"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;while&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;!&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/data/.runner&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;do&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;done;&lt;/span&gt;&lt;span class="nv"&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;forgejo-runner&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;daemon&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--config&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/data/config.yml"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;fj-runner:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;server&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;fj-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fj-pg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fj-runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FORGEJO__actions__ENABLED=true&lt;/code&gt; and the default actions URL match the documented defaults and are written out so the file explains itself. Forgejo publishes no &lt;code&gt;latest&lt;/code&gt; tag at all, by design, so pinning a full version like &lt;code&gt;15.0.10&lt;/code&gt; is the normal path.&lt;/p&gt;

&lt;p&gt;Start it, create an admin and register the runner:&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 up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; git server forgejo admin user create &lt;span class="nt"&gt;--admin&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--username&lt;/span&gt; labadmin &lt;span class="nt"&gt;--email&lt;/span&gt; you@example.com &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 12&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="nt"&gt;--must-change-password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; git server forgejo actions generate-runner-token | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;runner sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"cd /data &amp;amp;&amp;amp; forgejo-runner generate-config &amp;gt; config.yml &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  &amp;amp;&amp;amp; sed -i &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;s/^  network: .*/  network: fj_default/&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; config.yml &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  &amp;amp;&amp;amp; forgejo-runner register --no-interactive --instance http://server:3000 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
     --token &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt; --name lab-runner --labels docker:docker://alpine:3.24.2"&lt;/span&gt;
docker compose restart runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;network: fj_default&lt;/code&gt; line puts job containers on the Compose network so they can reach &lt;code&gt;http://server:3000&lt;/code&gt;. The runner mounts the Docker socket, which gives it root-level control of the host. Run it on a separate machine if untrusted people can push workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitLab CE with a runner
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.gitlab.com/install/docker/installation/" rel="noopener noreferrer"&gt;GitLab Docker install docs&lt;/a&gt; ask for an externally reachable hostname and say not to use &lt;code&gt;localhost&lt;/code&gt;. The lab mapped &lt;code&gt;gitlab.lab.test&lt;/code&gt; to &lt;code&gt;127.0.0.1&lt;/code&gt; in &lt;code&gt;/etc/hosts&lt;/code&gt; and gave the container the same name as a network alias, so the runner and job containers resolve it too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gl&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;gitlab&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;gitlab/gitlab-ce:19.5.0-ce.0&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitlab.lab.test&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;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256m"&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;GITLAB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GITLAB_ROOT_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;GITLAB_OMNIBUS_CONFIG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;external_url "http://gitlab.lab.test:8929"&lt;/span&gt;
        &lt;span class="s"&gt;registry_external_url "http://gitlab.lab.test:5050"&lt;/span&gt;
        &lt;span class="s"&gt;gitlab_rails["gitlab_shell_ssh_port"] = 2224&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8929:8929"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5050:5050"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:2224:22"&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;gl-config:/etc/gitlab&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gl-logs:/var/log/gitlab&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gl-data:/var/opt/gitlab&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;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gitlab.lab.test&lt;/span&gt;
  &lt;span class="na"&gt;runner&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;gitlab/gitlab-runner:alpine-v19.5.0&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gl-runner:/etc/gitlab-runner&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&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;gl-config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gl-logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gl-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gl-runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put &lt;code&gt;GITLAB_ROOT_PASSWORD=&lt;/code&gt; with a long random value in a &lt;code&gt;.env&lt;/code&gt; file next to it and run &lt;code&gt;docker compose up -d&lt;/code&gt;. Without that variable, GitLab writes a generated password to &lt;code&gt;/etc/gitlab/initial_root_password&lt;/code&gt; inside the container and deletes it on the first container restart after 24 hours.&lt;/p&gt;

&lt;p&gt;For the constrained run, these lines were appended to &lt;code&gt;GITLAB_OMNIBUS_CONFIG&lt;/code&gt; and the container recreated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# memory-constrained settings from docs.gitlab.com/omnibus/settings/memory_constrained_envs&lt;/span&gt;
&lt;span class="n"&gt;puma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"worker_processes"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;sidekiq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"concurrency"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="n"&gt;prometheus_monitoring&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"enable"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
&lt;span class="n"&gt;gitlab_rails&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"MALLOC_CONF"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"dirty_decay_ms:1000,muzzy_decay_ms:1000"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;gitaly&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"MALLOC_CONF"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"dirty_decay_ms:1000,muzzy_decay_ms:1000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"GITALY_COMMAND_SPAWN_MAX_PARALLEL"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"2"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create an instance runner under Admin, CI/CD, Runners (or with &lt;code&gt;POST /api/v4/user/runners&lt;/code&gt;), copy the &lt;code&gt;glrt-&lt;/code&gt; token and register:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;runner gitlab-runner register &lt;span class="nt"&gt;--non-interactive&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; http://gitlab.lab.test:8929 &lt;span class="nt"&gt;--token&lt;/span&gt; glrt-REPLACE_ME &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--executor&lt;/span&gt; docker &lt;span class="nt"&gt;--docker-image&lt;/span&gt; alpine:3.24.2 &lt;span class="nt"&gt;--docker-network-mode&lt;/span&gt; gl_default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  CI, registry, issues and merge requests
&lt;/h2&gt;

&lt;h3&gt;
  
  
  GitLab CI and Forgejo Actions
&lt;/h3&gt;

&lt;p&gt;Each instance got a &lt;code&gt;hello-ci&lt;/code&gt; repository with the same two-step job on &lt;code&gt;alpine:3.24.2&lt;/code&gt;. The GitLab version lives in &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;hello&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;alpine:3.24.2&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "hello from GitLab CI"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cat /etc/alpine-release&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline started on push and passed. The job page reported 4 seconds of run time after 1 second in the queue. With the constrained settings, the API reported 2.93 seconds for a rerun.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fen4duknbi8rip0mhozbh.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fen4duknbi8rip0mhozbh.webp" alt="GitLab CE 19.5.0 job log for job 1 in root/hello-ci, status Passed. The log shows the runner pulling alpine:3.24.2, fetching the repository, running echo hello from GitLab CI and printing 3.24.2, then Job succeeded. The sidebar shows a duration of 4 seconds and runner number 1, lab-runner." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Forgejo reads workflows from &lt;code&gt;.forgejo/workflows/&lt;/code&gt;, and falls back to &lt;code&gt;.github/workflows/&lt;/code&gt; when that folder is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hello&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "hello from Forgejo Actions"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cat /etc/alpine-release&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runs-on: docker&lt;/code&gt; matches the label given at registration. The run passed, and the Actions page reported about 1 second for the job.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftbbok60jlxx1no0miuy5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftbbok60jlxx1no0miuy5.webp" alt="Forgejo 15.0.10 Actions run page for labadmin/hello-ci, workflow hello.yml triggered by the add workflow commit. The hello job succeeded. Log sections show Set up job with runner version v13.2.0 starting image alpine:3.24.2 on network fj_default, then the echo step printing hello from Forgejo Actions, the cat step printing 3.24.2, and Complete job." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The syntax looks like GitHub Actions on purpose. The &lt;a href="https://forgejo.org/docs/latest/user/actions/overview/" rel="noopener noreferrer"&gt;Forgejo Actions docs&lt;/a&gt; say: "Forgejo Actions is designed to be familiar to users of GitHub Actions, but it is not designed to be compatible." Expect small edits when you bring workflows over. GitLab CI uses its own YAML format, so moving between the two means a rewrite either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Registry, issues and code review
&lt;/h3&gt;

&lt;p&gt;Both ship a container registry. On GitLab it is a separate bundled service that turns on when you set &lt;code&gt;registry_external_url&lt;/code&gt;, here on port 5050. The &lt;a href="https://docs.gitlab.com/install/requirements/" rel="noopener noreferrer"&gt;requirements page&lt;/a&gt; lists a domain and TLS certificates for it in real use. The lab used plain HTTP on a loopback name, which Docker accepts because it treats &lt;code&gt;127.0.0.0/8&lt;/code&gt; as an insecure registry by default. For any other address, Docker refuses plain HTTP unless the registry is listed under &lt;code&gt;insecure-registries&lt;/code&gt;, so put both registries behind HTTPS before you push from another machine. On Forgejo, the registry is part of the package registry and lives on the main web port, so the image name is &lt;code&gt;127.0.0.1:3000/labadmin/alpine-copy:3.24.2&lt;/code&gt;. Both pushes of the same Alpine image succeeded with the same digest, &lt;code&gt;sha256:7cefa58b...&lt;/code&gt;, after a &lt;code&gt;docker login&lt;/code&gt; with an access token.&lt;/p&gt;

&lt;p&gt;Issues and code review worked the same way on both. An issue, a branch, a commit and a pull or merge request with &lt;code&gt;Closes #1&lt;/code&gt; took a handful of API calls. Both linked the issue and showed the passing pipeline on the review page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvymcffbkwkz0ppxl55j1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvymcffbkwkz0ppxl55j1.webp" alt="GitLab CE merge request Tweak README, number 1, open from branch readme-tweak into main. The description says Closes number 1, a Pipeline number 3 passed widget shows a green check, approval is optional, and Ready to merge offers a Merge button with Delete source branch checked. The sidebar lists Lab issue: check CI badge under work items it closes." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitLab wraps that core in more DevOps menus. The CE project sidebar in the screenshots groups them under Plan, Code, Build, Secure, Deploy, Operate, Monitor and Analyze. Forgejo keeps the GitHub-style layout: issues, pull requests, projects, packages, wiki and Actions as tabs on the repository.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktx73uhr3p45p355zbjo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktx73uhr3p45p355zbjo.webp" alt="Forgejo 15.0.10 pull request Tweak README number 2 in labadmin/hello-ci, open, wanting to merge 1 commit from readme-tweak into main. The comment says Closes number 1. A checks box reads All checks were successful with hello push successful in 1s, the pull request can be merged automatically, and a Create merge commit button is shown." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations, upgrades and backups
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Migrating a real repository
&lt;/h3&gt;

&lt;p&gt;Both instances imported &lt;code&gt;https://github.com/plausible/community-edition&lt;/code&gt; over HTTPS as plain Git. Forgejo returned from &lt;code&gt;POST /api/v1/repos/migrate&lt;/code&gt; in 0.4 seconds with 31 commits and 11,425 KB on disk.&lt;/p&gt;

&lt;p&gt;GitLab 19.5.0 refused at first. On a fresh install, &lt;code&gt;import_sources&lt;/code&gt; was an empty list, so import by URL was off and the API returned 403. Enabling &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;github&lt;/code&gt; under Admin, Settings, General, Import and export settings fixed it, but the same request kept returning 403 for about three minutes before it succeeded. After that, the import finished within a few seconds, with the same 31 commits and 11.4 MB.&lt;/p&gt;

&lt;p&gt;For a move away from GitLab, the Forgejo migrator can also bring issues, labels, milestones, releases and merge requests when you give it a GitLab access token. The &lt;a href="https://docs.codeberg.org/advanced/migrating-repos/" rel="noopener noreferrer"&gt;Codeberg migration docs&lt;/a&gt; list GitLab among the services with metadata support. Forgejo uses one number space for issues and pull requests, so merge request numbers shift on the way in. &lt;a href="https://codeberg.org/forgejo/forgejo/pulls/1790" rel="noopener noreferrer"&gt;Forgejo pull request #1790&lt;/a&gt; offsets them by the highest issue number, and &lt;a href="https://codeberg.org/forgejo/forgejo/pulls/10077" rel="noopener noreferrer"&gt;pull request #10077&lt;/a&gt;, merged in November 2025, rewrites &lt;code&gt;!N&lt;/code&gt; references in comments to match.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrades and backups
&lt;/h3&gt;

&lt;p&gt;GitLab ships a minor release every month on the third Thursday, patch releases twice a month and one major release a year, per its &lt;a href="https://docs.gitlab.com/policy/maintenance/" rel="noopener noreferrer"&gt;maintenance policy&lt;/a&gt;. Security fixes reach the current release and the two before it. Jumps between versions must pass through &lt;a href="https://docs.gitlab.com/update/upgrade_paths/" rel="noopener noreferrer"&gt;required upgrade stops&lt;/a&gt;. For GitLab 19 those are 19.2, 19.5, 19.8 and 19.11, so the tested 19.5.0 is itself a stop. The &lt;a href="https://docs.gitlab.com/update/docker/" rel="noopener noreferrer"&gt;Docker upgrade docs&lt;/a&gt; say to back up at least the database and the secrets file, upgrade the runner to the same version, change the image tag, then run &lt;code&gt;docker compose pull&lt;/code&gt; and &lt;code&gt;docker compose up -d&lt;/code&gt;. The upgrade path page says to let background migrations finish before the next stop.&lt;/p&gt;

&lt;p&gt;Forgejo releases quarterly, with one LTS a year. The &lt;a href="https://forgejo.org/docs/latest/admin/release-schedule/" rel="noopener noreferrer"&gt;release schedule&lt;/a&gt; gives 15.0 LTS support until July 15, 2027, while 16.0 reaches end of life on October 29, 2026. The FAQ says major upgrades need a manual check, which is why the project publishes no &lt;code&gt;latest&lt;/code&gt; tag. Inside a line, the &lt;code&gt;15&lt;/code&gt; and &lt;code&gt;15.0&lt;/code&gt; tags follow the newest minor and patch release.&lt;/p&gt;

&lt;p&gt;For backups, GitLab: &lt;code&gt;docker compose exec gitlab gitlab-backup create&lt;/code&gt; ran in 24.6 seconds and wrote a 16.2 MB tar to &lt;code&gt;/var/opt/gitlab/backups/&lt;/code&gt;. It ended with a warning that &lt;code&gt;gitlab.rb&lt;/code&gt; and &lt;code&gt;gitlab-secrets.json&lt;/code&gt; are not in the archive. The warning adds: "You will need these files to restore a backup." Copy both separately.&lt;/p&gt;

&lt;p&gt;Forgejo: &lt;code&gt;docker compose exec -u git -w /tmp server forgejo dump --type tar.gz --file /tmp/forgejo-dump.tar.gz&lt;/code&gt; ran in 0.4 seconds and wrote a 15.6 MB archive with the repositories, packages, &lt;code&gt;forgejo-db.sql&lt;/code&gt; and &lt;code&gt;app.ini&lt;/code&gt;. That &lt;code&gt;app.ini&lt;/code&gt; holds &lt;code&gt;SECRET_KEY&lt;/code&gt;, &lt;code&gt;INTERNAL_TOKEN&lt;/code&gt; and &lt;code&gt;JWT_SECRET&lt;/code&gt;, so treat the dump file as a secret. Copy either archive off the box and test a restore on a scratch instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Licensing, federation and which to pick
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Licensing and federation
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://gitlab.com/gitlab-org/gitlab/-/blob/master/LICENSE" rel="noopener noreferrer"&gt;GitLab LICENSE file&lt;/a&gt; puts the code outside the &lt;code&gt;ee/&lt;/code&gt; directory under the MIT Expat license, with the docs under CC BY-SA 4.0. Code in &lt;code&gt;ee/&lt;/code&gt; falls under the &lt;a href="https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/LICENSE" rel="noopener noreferrer"&gt;EE license&lt;/a&gt;, which allows production use only with a GitLab subscription. The &lt;code&gt;gitlab-ce&lt;/code&gt; image is the MIT edition, and the 19.5.0 API reported &lt;code&gt;"enterprise":false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://forgejo.org/faq/" rel="noopener noreferrer"&gt;Forgejo FAQ&lt;/a&gt; says versions 9.0 and later are GPL v3 or later, versions up to 8.0 were MIT, and the project is not AGPL.&lt;/p&gt;

&lt;p&gt;Federation through ForgeFed is a stated Forgejo goal. The FAQ says: "Federation is under active development, and is considered experimental." It warns of breaking changes that can burn the domain for future federation. The &lt;a href="https://forgejo.org/docs/latest/admin/config-cheat-sheet/" rel="noopener noreferrer"&gt;config cheat sheet&lt;/a&gt; defaults &lt;code&gt;[federation] ENABLED&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;, and the ActivityPub user endpoint returned 404 on the 17.0.0 lab install. Federated stars were the first user-facing feature, per a &lt;a href="https://archive.fosdem.org/2025/events/attachments/fosdem-2025-5610-show-and-tell-federation-at-forgejo/slides/238275/2025_02_0_i9uW1ym.pdf" rel="noopener noreferrer"&gt;FOSDEM 2025 talk&lt;/a&gt;. Treat federation as a future feature, not a reason to switch today.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which one to pick
&lt;/h3&gt;

&lt;p&gt;Pick Forgejo when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server is a small VPS, a homelab box or anything with 1 to 4 GB of RAM. The whole stack idled under 300 MiB here.&lt;/li&gt;
&lt;li&gt;You want GitHub-style repos, pull requests, packages and Actions with little upkeep, and a quarterly release rhythm with a yearly LTS.&lt;/li&gt;
&lt;li&gt;You prefer a copyleft license and a community project whose domains are held by the non-profit Codeberg e.V.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick GitLab CE when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want one integrated DevOps tool: mature CI/CD with stages, a merge request flow and planning in the same UI.&lt;/li&gt;
&lt;li&gt;You can give it 8 GB of RAM or more, or accept the constrained profile and its trade-offs at about 2.4 GiB.&lt;/li&gt;
&lt;li&gt;You may want paid EE features later. The memory-constrained docs page says you can always upgrade from CE to EE.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a solo developer or a small team, Forgejo covers the same daily Git work. On this host it used about a thirtieth of the idle RAM of default GitLab. Add an &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma monitor&lt;/a&gt; on the login page either way, and if you want push-to-deploy on top, connect the repo to &lt;a href="https://deploylark.com/posts/coolify-install-vps-first-app/" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Resources and CI
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does GitLab use so much RAM when idle?
&lt;/h4&gt;

&lt;p&gt;It runs many services in one container, and on this 8 vCPU host the ten Ruby processes held about 1 GB each. A default 19.5.0 container idled at 8,657.7 MiB (about 8.5 GiB) here, close to the 8.452 GB that a user reported on a fresh container in the &lt;a href="https://forum.gitlab.com/t/high-ram-usage/17546" rel="noopener noreferrer"&gt;GitLab forum thread on high RAM usage&lt;/a&gt;. The memory-constrained settings brought it to about 2.4 GiB.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can GitLab run on a 4 GB VPS?
&lt;/h4&gt;

&lt;p&gt;It can start with the constrained profile, which idled at 2,466.5 MiB in this run. GitLab documents 8 GB as the floor for constrained single-node installs and 16 GB as the baseline. In a &lt;a href="https://forum.gitlab.com/t/upgrade-to-gitlab-17-increased-memory-consumption/108296" rel="noopener noreferrer"&gt;forum thread about memory after the 17.0 upgrade&lt;/a&gt;, a reply calls 4 GB the absolute minimum and recommends 8 GB.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can Forgejo Actions run GitHub Actions workflows unchanged?
&lt;/h4&gt;

&lt;p&gt;Not always. The Forgejo docs say Actions is designed to be familiar to GitHub users but not designed to be compatible, so expect small edits. Forgejo also falls back to &lt;code&gt;.github/workflows/&lt;/code&gt; when &lt;code&gt;.forgejo/workflows/&lt;/code&gt; is missing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migration, licensing and federation
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Can you migrate from GitLab to Forgejo with issues and merge requests?
&lt;/h4&gt;

&lt;p&gt;Yes, with a GitLab access token in the migration form. Merge requests become pull requests numbered after the highest issue number, and &lt;code&gt;!N&lt;/code&gt; references in comments are rewritten since &lt;a href="https://codeberg.org/forgejo/forgejo/pulls/10077" rel="noopener noreferrer"&gt;pull request #10077&lt;/a&gt;. Cross-repository references are still tracked in &lt;a href="https://codeberg.org/forgejo/forgejo/issues/11332" rel="noopener noreferrer"&gt;issue #11332&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is Forgejo licensed under the AGPL?
&lt;/h4&gt;

&lt;p&gt;No. The &lt;a href="https://forgejo.org/faq/" rel="noopener noreferrer"&gt;Forgejo FAQ&lt;/a&gt; says GPL v3 or later from version 9.0, MIT up to 8.0, and that switching to AGPL or EUPL was proposed without agreement.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can Forgejo instances federate with each other yet?
&lt;/h4&gt;

&lt;p&gt;Only experimentally. Federation is off by default, the FAQ calls it experimental, and moderation and access control are still missing. The 2025 FOSDEM talk marked federated stars as done and listed following, comments and pull requests across instances as roadmap items.&lt;/p&gt;

&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/gitlab-ce-vs-forgejo-docker/" rel="noopener noreferrer"&gt;GitLab CE vs Forgejo: Self-Hosted Git on Docker, Measured&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>gitlab</category>
      <category>forgejo</category>
    </item>
  </channel>
</rss>
