<?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>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>
    <item>
      <title>Self-Host Plausible Community Edition with Docker</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:30:42 +0000</pubDate>
      <link>https://dev.to/deploylark/self-host-plausible-community-edition-with-docker-321h</link>
      <guid>https://dev.to/deploylark/self-host-plausible-community-edition-with-docker-321h</guid>
      <description>&lt;p&gt;Plausible Community Edition is a self-hosted, cookie-free analytics stack. The same product family powers &lt;a href="https://plausible.io/" rel="noopener noreferrer"&gt;Plausible Cloud&lt;/a&gt;, and CE is the free, AGPL-licensed release you run yourself. On October 18, 2026, the &lt;a href="https://github.com/plausible/analytics" rel="noopener noreferrer"&gt;analytics repository&lt;/a&gt; had about 29,500 stars, and the &lt;a href="https://github.com/plausible/community-edition" rel="noopener noreferrer"&gt;community-edition compose repo&lt;/a&gt; had about 2,900. This guide installs CE v3.2.1 from the official Compose files, pins every image tag, puts the dashboard on &lt;code&gt;127.0.0.1&lt;/code&gt;, and confirms a real pageview on a lab site.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami Docker and PostgreSQL guide&lt;/a&gt; covers a lighter Postgres-only stack. Plausible CE adds ClickHouse for events and ships a different feature set from Cloud. For a broader app platform on the same VPS, see the &lt;a href="https://deploylark.com/posts/coolify-install-vps-first-app/" rel="noopener noreferrer"&gt;Coolify install guide&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Clone &lt;code&gt;plausible/community-edition&lt;/code&gt; at tag &lt;code&gt;v3.2.1&lt;/code&gt;, set &lt;code&gt;BASE_URL&lt;/code&gt; and a 64-byte &lt;code&gt;SECRET_KEY_BASE&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, and pin Postgres and ClickHouse to exact tags in &lt;code&gt;compose.override.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Bind the app to &lt;code&gt;127.0.0.1:8000&lt;/code&gt; and put &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; or Caddy in front with HTTPS. Keep &lt;code&gt;BASE_URL&lt;/code&gt; equal to the public URL.&lt;/li&gt;
&lt;li&gt;Open the dashboard, create the first account, add a site, paste the generated script into the site &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, and confirm a pageview on the dashboard.&lt;/li&gt;
&lt;li&gt;Expect about 2 GB of RAM for a calm ClickHouse setup. CPU needs SSE 4.2 or NEON.&lt;/li&gt;
&lt;li&gt;Upgrade by checking out a newer CE tag and running &lt;code&gt;docker compose up -d&lt;/code&gt;. Back up Postgres with &lt;code&gt;pg_dump&lt;/code&gt; and keep the ClickHouse volumes.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  What CE gives you, and what stays on Cloud
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Feature and licence differences
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://plausible.io/docs/self-hosting" rel="noopener noreferrer"&gt;self-hosting docs&lt;/a&gt; compare Cloud and CE in one table. The points that matter for a home lab or a small team are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You own the server: installs, upgrades, backups, capacity and uptime are yours.&lt;/li&gt;
&lt;li&gt;CE releases twice a year. Cloud ships features continuously.&lt;/li&gt;
&lt;li&gt;Marketing funnels, user journeys, ecommerce revenue goals, SSO and the sites API are Cloud-only.&lt;/li&gt;
&lt;li&gt;Bot filtering on CE is the basic User-Agent and referrer-spam path. Cloud adds data-center IP filtering and a broader bot model.&lt;/li&gt;
&lt;li&gt;Premium human support is Cloud-only. Self-hosting is community-supported only, per the &lt;a href="https://plausible.io/docs/self-hosting" rel="noopener noreferrer"&gt;self-hosting docs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;CE is AGPL-3.0 (or later). Cloud is a paid subscription that funds the project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you modify CE and offer it as a network service, AGPL requires you to share the corresponding source with users of that service. Read the licence before you ship a public SaaS on top of CE.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware and CPU requirements
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/plausible/community-edition" rel="noopener noreferrer"&gt;CE README&lt;/a&gt; asks for Docker, Docker Compose, a CPU with SSE 4.2 or NEON (ClickHouse needs it), and at least 2 GB of RAM so ClickHouse and Plausible stay clear of OOM kills. The compose file mounts low-resource ClickHouse settings for small servers. On the lab host used for this guide (8 vCPUs with SSE 4.2, Debian 13), the three containers used about 710 MB of RAM after the first pageviews: about 450 MB for Plausible, 185 MB for ClickHouse and 80 MB for Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install the official Compose stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Clone the repo and set &lt;code&gt;.env&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The install path is the &lt;a href="https://github.com/plausible/community-edition" rel="noopener noreferrer"&gt;community-edition&lt;/a&gt; repository, not a hand-rolled compose file. Clone a specific tag so the ClickHouse config files match the app:&lt;br&gt;
&lt;/p&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;-b&lt;/span&gt; v3.2.1 &lt;span class="nt"&gt;--single-branch&lt;/span&gt; https://github.com/plausible/community-edition plausible-ce
&lt;span class="nb"&gt;cd &lt;/span&gt;plausible-ce
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt; with the two required variables from the &lt;a href="https://github.com/plausible/community-edition/wiki/Configuration" rel="noopener noreferrer"&gt;configuration wiki&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="nb"&gt;touch&lt;/span&gt; .env
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BASE_URL=https://plausible.example.com"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SECRET_KEY_BASE=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 48&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"HTTP_PORT=8000"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BASE_URL&lt;/code&gt; has no default. It drives link generation and WebSocket origin checks. Set it to the URL people will type in the browser, including &lt;code&gt;https://&lt;/code&gt; once a reverse proxy terminates TLS. For a local evaluation, the README tip is &lt;code&gt;BASE_URL=http://localhost:8000&lt;/code&gt; (or any free port). The lab run used &lt;code&gt;http://127.0.0.1:8000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SECRET_KEY_BASE&lt;/code&gt; also has no default. It must be at least 64 bytes. &lt;code&gt;openssl rand -base64 48&lt;/code&gt; produces a 64-character value that meets that rule. Runtime config raises if the key is missing: &lt;code&gt;SECRET_KEY_BASE configuration option is required&lt;/code&gt;. A short key fails later with &lt;code&gt;cookie store expects conn.secret_key_base to be at least 64 bytes&lt;/code&gt;, which matches &lt;a href="https://github.com/plausible/analytics/discussions/3516" rel="noopener noreferrer"&gt;discussion #3516&lt;/a&gt; and &lt;a href="https://github.com/plausible/analytics/issues/5612" rel="noopener noreferrer"&gt;issue #5612&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pin image tags and bind to localhost
&lt;/h3&gt;

&lt;p&gt;Upstream &lt;code&gt;compose.yml&lt;/code&gt; at v3.2.1 uses &lt;code&gt;ghcr.io/plausible/community-edition:v3.2.1&lt;/code&gt;, &lt;code&gt;postgres:16-alpine&lt;/code&gt; and &lt;code&gt;clickhouse/clickhouse-server:24.12-alpine&lt;/code&gt;. The last two float. On October 18, 2026, those tags resolved to PostgreSQL 16.15 and ClickHouse 24.12.6.70. Put the exact tags in &lt;code&gt;compose.override.yml&lt;/code&gt; so future &lt;code&gt;docker compose pull&lt;/code&gt; calls stay on known builds, and publish the app only on the loopback address as the &lt;a href="https://github.com/plausible/community-edition/wiki/reverse-proxy" rel="noopener noreferrer"&gt;reverse-proxy wiki&lt;/a&gt; shows:&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;plausible_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:16.15-alpine&lt;/span&gt;
  &lt;span class="na"&gt;plausible_events_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;clickhouse/clickhouse-server:24.12.6.70-alpine&lt;/span&gt;
  &lt;span class="na"&gt;plausible&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/plausible/community-edition:v3.2.1&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:8000:8000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The override file is the supported place for local changes. It survives &lt;code&gt;git pull&lt;/code&gt; of a new CE tag better than editing &lt;code&gt;compose.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The stack looks like this once it is up:&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%2F2e4i9qusnmdantayqzch.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%2F2e4i9qusnmdantayqzch.webp" alt="Diagram of the Plausible Community Edition Docker Compose stack. A visitor browser sends POST /api/event to the plausible container (CE v3.2.1 on 127.0.0.1:8000). A second browser reaches the dashboard over HTTP. The plausible container uses plausible_db (PostgreSQL 16.15) and plausible_events_db (ClickHouse 24.12.6.70). An optional reverse proxy (Caddy or Nginx Proxy Manager) terminates TLS. Named volumes are db-data, event-data, event-logs and plausible-data." width="800" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose ps
curl &lt;span class="nt"&gt;--head&lt;/span&gt; http://127.0.0.1:8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a warm host with images already pulled, Postgres and ClickHouse became healthy in under a minute, and &lt;code&gt;/register&lt;/code&gt; returned HTTP 200 about 15 seconds after the app container started. An &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; HTTP monitor on &lt;code&gt;/login&lt;/code&gt; is enough to notice when the dashboard stops answering. The first visit is the registration 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%2Fwvdv42ec6vy9d10gku9f.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%2Fwvdv42ec6vy9d10gku9f.webp" alt="Plausible Community Edition registration page titled Register your Plausible CE account, with a five-step progress bar starting at Register, and a form for full name, email, password and confirm password, plus a Create my account button. Password hint says Min 12 characters." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create that first account right away. Default registration is invite-only after the first user (&lt;code&gt;DISABLE_REGISTRATION&lt;/code&gt; defaults to &lt;code&gt;invite_only&lt;/code&gt; in the wiki). The first account owns the instance.&lt;/p&gt;

&lt;p&gt;Default database credentials in the compose file are &lt;code&gt;postgres&lt;/code&gt; / &lt;code&gt;postgres&lt;/code&gt;. Change them for anything exposed beyond a private lab by setting &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; and a matching &lt;code&gt;DATABASE_URL&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; (see the configuration wiki).&lt;/p&gt;

&lt;h2&gt;
  
  
  Put HTTPS in front
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reverse proxy layout
&lt;/h3&gt;

&lt;p&gt;From CE 2.1.2 onward, Plausible can request Let's Encrypt certificates itself when &lt;code&gt;HTTP_PORT=80&lt;/code&gt; and &lt;code&gt;HTTPS_PORT=443&lt;/code&gt; are published. Behind an existing proxy, keep &lt;code&gt;HTTP_PORT=8000&lt;/code&gt;, bind &lt;code&gt;127.0.0.1:8000&lt;/code&gt;, and terminate TLS on the proxy. That is the layout this guide tested.&lt;/p&gt;

&lt;p&gt;A minimal &lt;a href="https://github.com/plausible/community-edition/wiki/reverse-proxy" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; config from the wiki is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plausible.example.com {
    reverse_proxy localhost:8000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; works the same way: proxy host to &lt;code&gt;http://127.0.0.1:8000&lt;/code&gt;, force SSL, and enable WebSockets so &lt;code&gt;/live/websocket&lt;/code&gt; stays up for the live dashboard. The same &lt;code&gt;127.0.0.1&lt;/code&gt; binding pattern shows up in the &lt;a href="https://deploylark.com/posts/vaultwarden-https-required-fix/" rel="noopener noreferrer"&gt;Vaultwarden HTTPS guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/paperless-ngx-docker-compose-ocr/" rel="noopener noreferrer"&gt;Paperless-ngx Compose guide&lt;/a&gt;. The wiki's Nginx sample proxies that path with &lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep BASE_URL on the public hostname
&lt;/h3&gt;

&lt;p&gt;Whatever you pick, &lt;code&gt;BASE_URL&lt;/code&gt; must match the public URL. With a wrong host, the dashboard HTML can load while the LiveView WebSocket returns 403. &lt;a href="https://github.com/plausible/community-edition/issues/205" rel="noopener noreferrer"&gt;Issue #205&lt;/a&gt; shows the log line &lt;code&gt;Could not check origin for Phoenix.Socket transport&lt;/code&gt; when the browser origin and &lt;code&gt;BASE_URL&lt;/code&gt; disagree. After you change &lt;code&gt;.env&lt;/code&gt;, run &lt;code&gt;docker compose up -d&lt;/code&gt; so the container recreates. A plain &lt;code&gt;docker compose restart&lt;/code&gt; does not reload env vars.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a site and confirm a pageview
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install the tracking script
&lt;/h3&gt;

&lt;p&gt;After registration, the wizard asks for a domain and timezone, then shows a site-specific script. Paste it into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of the site. On CE v3.2.1 the snippet looks like this shape (the &lt;code&gt;pa-…&lt;/code&gt; filename is unique per site):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Privacy-friendly analytics by Plausible --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://plausible.example.com/js/pa-xxxxxxxx.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(){(&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;{}};&lt;/span&gt;
  &lt;span class="nx"&gt;plausible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxzte3i2od3k0reto4tm.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%2Fxxzte3i2od3k0reto4tm.webp" alt="Plausible CE Install Plausible step showing the Script tab, a COPY box with the tracking snippet that loads a pa- script from the instance URL, optional measurements for outbound links, file downloads and form submissions, and a Verify Script installation button." width="800" height="686"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Optional checkboxes turn on outbound-link, file-download and form-submission measurements. The new script updates those choices from the dashboard. Legacy snippets that used &lt;code&gt;data-domain&lt;/code&gt; still work. The &lt;a href="https://plausible.io/docs/script-update-guide" rel="noopener noreferrer"&gt;script update guide&lt;/a&gt; covers migration.&lt;/p&gt;

&lt;p&gt;The script's event endpoint is taken from &lt;code&gt;BASE_URL&lt;/code&gt; (it posts to &lt;code&gt;{BASE_URL}/api/event&lt;/code&gt;). Sites that only allowlist their own analytics host in &lt;code&gt;Content-Security-Policy&lt;/code&gt; need that origin in &lt;code&gt;connect-src&lt;/code&gt;, or an &lt;code&gt;endpoint&lt;/code&gt; override in &lt;code&gt;plausible.init&lt;/code&gt; as shown in &lt;a href="https://github.com/plausible/analytics/discussions/6126" rel="noopener noreferrer"&gt;discussion #6126&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confirm the first pageview
&lt;/h3&gt;

&lt;p&gt;Open the site in a normal browser and watch the CE dashboard for the domain. The lab run added &lt;code&gt;example.test&lt;/code&gt;, served a static page with the generated snippet, and recorded pageviews. After four accepted events, the dashboard showed 4 unique visitors, 4 total pageviews and the &lt;code&gt;/&lt;/code&gt; path under Top pages:&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%2Fsbjpc4d3ll1pgjedmcuw.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%2Fsbjpc4d3ll1pgjedmcuw.webp" alt="Plausible CE dashboard for example.test showing 4 current visitors, Unique visitors 4, Total visits 4, Total pageviews 4, Views per visit 1, Bounce rate 100 percent, a today graph with a spike to 4, Top sources Direct / None, and Top pages /." width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two checks that catch false starts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The script ignores localhost and &lt;code&gt;127.0.0.1&lt;/code&gt; by design. The browser console prints &lt;code&gt;Ignoring Event: localhost&lt;/code&gt;. That matches &lt;a href="https://github.com/plausible/community-edition/issues/92" rel="noopener noreferrer"&gt;community-edition issue #92&lt;/a&gt; and the &lt;a href="https://plausible.io/docs/troubleshoot-integration" rel="noopener noreferrer"&gt;troubleshooting docs&lt;/a&gt;. Test on a real hostname (a &lt;code&gt;/etc/hosts&lt;/code&gt; entry is enough in a lab).&lt;/li&gt;
&lt;li&gt;A 202 from &lt;code&gt;POST /api/event&lt;/code&gt; is not always a stored event. The troubleshooting docs say to look for &lt;code&gt;x-plausible-dropped: 1&lt;/code&gt; when a visit is filtered. The lab posts that used a normal browser User-Agent and an &lt;code&gt;X-Forwarded-For&lt;/code&gt; address returned 202 without that header, and ClickHouse held four rows in &lt;code&gt;plausible_events_db.events_v2&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Upgrades and backups
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Upgrades
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/plausible/community-edition/wiki/upgrade" rel="noopener noreferrer"&gt;upgrade wiki&lt;/a&gt; recommends pinning a full patch tag such as &lt;code&gt;v3.2.1&lt;/code&gt;. Security fixes are not backported, so plan to move forward. v3.2.1 itself is a security release that removes an exposed &lt;code&gt;/storybook&lt;/code&gt; endpoint (&lt;a href="https://github.com/plausible/analytics/security/advisories/GHSA-mhcv-h7gf-57cf" rel="noopener noreferrer"&gt;CVE-2026-8467&lt;/a&gt;). General steps for a patch or minor bump:&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;cd &lt;/span&gt;plausible-ce
git fetch origin v3.2.1:v3.2.1
git checkout v3.2.1
&lt;span class="c"&gt;# keep your .env and compose.override.yml&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the release notes before every major bump. Some releases change ClickHouse config files in the community-edition repo, which is why the checkout step matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backups
&lt;/h3&gt;

&lt;p&gt;Postgres holds users and site settings. ClickHouse holds events. The &lt;a href="https://github.com/plausible/community-edition/wiki/Upgrade-PostgreSQL" rel="noopener noreferrer"&gt;Upgrade PostgreSQL&lt;/a&gt; wiki shows a &lt;code&gt;pg_dump&lt;/code&gt; path that doubles as a backup:&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 stop plausible
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;plausible_db sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"pg_dump -U postgres plausible_db &amp;gt; plausible_db.bak"&lt;/span&gt;
docker compose &lt;span class="nb"&gt;cp &lt;/span&gt;plausible_db:plausible_db.bak ./plausible_db.bak
docker compose start plausible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep copies of the ClickHouse volumes (&lt;code&gt;event-data&lt;/code&gt;, and &lt;code&gt;event-logs&lt;/code&gt; if you care about server logs) with your volume backup tool. There is no separate CE wiki page that ships a one-line ClickHouse dump. Restoring across major ClickHouse versions needs extra care, so keep the image tag pin in &lt;code&gt;compose.override.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also back up &lt;code&gt;.env&lt;/code&gt;. Losing &lt;code&gt;SECRET_KEY_BASE&lt;/code&gt; logs everyone out of the dashboard. Site data in Postgres and ClickHouse stays, as &lt;a href="https://github.com/plausible/analytics/discussions/3516#discussioncomment-7489146" rel="noopener noreferrer"&gt;ruslandoga noted&lt;/a&gt; when rotating the key.&lt;/p&gt;

&lt;p&gt;CE can also send account activation, password resets and optional reports when SMTP or a mail provider adapter is configured. The configuration wiki lists &lt;code&gt;SMTP_HOST_ADDR&lt;/code&gt;, &lt;code&gt;MAILER_EMAIL&lt;/code&gt; and the Postmark, Mailgun, Mandrill and SendGrid variables. Without SMTP, local accounts still work when &lt;code&gt;ENABLE_EMAIL_VERIFICATION&lt;/code&gt; stays at its default &lt;code&gt;false&lt;/code&gt;. Those providers were not exercised in the lab run.&lt;/p&gt;

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

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

&lt;h4&gt;
  
  
  How much RAM does Plausible CE need?
&lt;/h4&gt;

&lt;p&gt;The CE README recommends at least 2 GB so ClickHouse and Plausible avoid OOM kills. The lab stack with the bundled low-resource ClickHouse config used about 710 MB of RAM after the first pageviews. Builds and heavy queries need more headroom than idle stats.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does the dashboard load but live data stay empty?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;BASE_URL&lt;/code&gt; usually does not match the URL in the browser. The HTML can still load while &lt;code&gt;/live/websocket&lt;/code&gt; fails origin checks. Set &lt;code&gt;BASE_URL&lt;/code&gt; to the public URL, then recreate the container with &lt;code&gt;docker compose up -d&lt;/code&gt;. See &lt;a href="https://github.com/plausible/community-edition/issues/205" rel="noopener noreferrer"&gt;issue #205&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why is SECRET_KEY_BASE required, and how long must it be?
&lt;/h4&gt;

&lt;p&gt;The configuration wiki requires it and asks for at least 64 bytes. Generate one with &lt;code&gt;openssl rand -base64 48&lt;/code&gt;. A missing key stops startup. A short key breaks cookie sessions with &lt;code&gt;cookie store expects conn.secret_key_base to be at least 64 bytes&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracking and HTTPS
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does the browser say "Ignoring Event: localhost"?
&lt;/h4&gt;

&lt;p&gt;The tracker refuses localhost and loopback hosts so local development noise stays out of production stats. Use a real hostname for the test site, or a hosts-file entry that is not &lt;code&gt;localhost&lt;/code&gt;. Maintainer replies in &lt;a href="https://github.com/plausible/community-edition/issues/92" rel="noopener noreferrer"&gt;issue #92&lt;/a&gt; call out the same console warning.&lt;/p&gt;

&lt;h4&gt;
  
  
  Which Cloud features are missing from CE?
&lt;/h4&gt;

&lt;p&gt;Per the &lt;a href="https://plausible.io/docs/self-hosting" rel="noopener noreferrer"&gt;self-hosting docs&lt;/a&gt;: marketing funnels, user journeys, ecommerce revenue goals, SSO and the sites API. Bot filtering is also simpler on CE, and there is no premium support channel.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do you put HTTPS in front of CE?
&lt;/h4&gt;

&lt;p&gt;Either publish ports 80 and 443 and let CE request certificates, or bind &lt;code&gt;127.0.0.1:8000&lt;/code&gt; and terminate TLS on Caddy or Nginx Proxy Manager. Keep &lt;code&gt;BASE_URL&lt;/code&gt; on the &lt;code&gt;https://&lt;/code&gt; URL in both cases. The &lt;a href="https://github.com/plausible/community-edition/wiki/reverse-proxy" rel="noopener noreferrer"&gt;reverse-proxy wiki&lt;/a&gt; has sample configs.&lt;/p&gt;

&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/plausible-community-edition-self-host/" rel="noopener noreferrer"&gt;Self-Host Plausible Community Edition with Docker&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>analytics</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Fix the Vaultwarden "HTTPS Required" Secure Context Error</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:26:16 +0000</pubDate>
      <link>https://dev.to/deploylark/fix-the-vaultwarden-https-required-secure-context-error-2jmp</link>
      <guid>https://dev.to/deploylark/fix-the-vaultwarden-https-required-secure-context-error-2jmp</guid>
      <description>&lt;p&gt;If you start Vaultwarden with a plain &lt;code&gt;docker run -p 8000:80&lt;/code&gt; and open it from another machine, you don't get a login page. You get this:&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%2F0fwewkipm4pv8ookl16k.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%2F0fwewkipm4pv8ookl16k.webp" alt="Vaultwarden web vault opened over plain HTTP from another machine, showing only the message: You are not using a secure context which is required for the Subtle Crypto API to work. You need to enable HTTPS!" width="800" height="500"&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;You are not using a secure context which is required for the Subtle Crypto API to work. You need to enable HTTPS!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a bug, and no Vaultwarden setting turns it off. Lots of people hit it, because Vaultwarden is a very common first self-hosted app. On October 14, 2026, the &lt;a href="https://github.com/dani-garcia/vaultwarden" rel="noopener noreferrer"&gt;Vaultwarden repository&lt;/a&gt; had about 69,000 GitHub stars, and the &lt;a href="https://hub.docker.com/r/vaultwarden/server" rel="noopener noreferrer"&gt;&lt;code&gt;vaultwarden/server&lt;/code&gt; image&lt;/a&gt; had been pulled more than 344 million times from Docker Hub. Below I reproduce the error, explain why it happens, and fix it with a reverse proxy.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The web vault needs the browser's &lt;code&gt;crypto.subtle&lt;/code&gt; API, which only exists in a secure context. Plain HTTP from a LAN IP isn't one.&lt;/li&gt;
&lt;li&gt;No Vaultwarden setting turns this off. Put a reverse proxy with a certificate in front. Caddy with &lt;code&gt;reverse_proxy 127.0.0.1:8000&lt;/code&gt; fixed it in my test.&lt;/li&gt;
&lt;li&gt;Bind Vaultwarden to &lt;code&gt;127.0.0.1:8000&lt;/code&gt; and set &lt;code&gt;DOMAIN&lt;/code&gt; to the HTTPS URL people will type.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http://localhost&lt;/code&gt; loads the page, but current Bitwarden clients require HTTPS, so don't rely on it.&lt;/li&gt;
&lt;li&gt;Once your own account exists, set &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; to &lt;code&gt;"false"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce the error
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Start Vaultwarden over plain HTTP
&lt;/h3&gt;

&lt;p&gt;This is the "broken" compose file I started with:&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;vaultwarden&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;vaultwarden/server:1.37.4&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;vaultwarden&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;./vw-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;8000:80"&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;$ docker compose up -d
$ docker logs vaultwarden
|                           Version 1.37.4                           |
[start][INFO] Rocket has launched from http://0.0.0.0:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I opened it from another machine at &lt;code&gt;http://&amp;lt;your server's LAN IP&amp;gt;:8000/&lt;/code&gt;, for example &lt;code&gt;http://192.168.1.50:8000/&lt;/code&gt;. In my lab the address happened to be &lt;code&gt;172.30.0.2&lt;/code&gt;. In the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;window.isSecureContext = false
typeof crypto.subtle   = "undefined"
Error: Could not instantiate WebCryptoFunctionService. Could not locate Subtle crypto.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What about &lt;a href="http://localhost" rel="noopener noreferrer"&gt;http://localhost&lt;/a&gt;?
&lt;/h3&gt;

&lt;p&gt;Opening &lt;code&gt;http://localhost:8000/&lt;/code&gt; does count as a secure context, and the login page rendered:&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%2Fhz79vsfgr53l8rscs62v.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%2Fhz79vsfgr53l8rscs62v.webp" alt="Vaultwarden web vault login page rendered over http://localhost:8000" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the console logged this right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to fetch ServerConfig from http://localhost:8000/api ... Insecure URL not allowed. All URLs must use HTTPS.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matches what a Vaultwarden contributor wrote in &lt;a href="https://github.com/dani-garcia/vaultwarden/pull/7137" rel="noopener noreferrer"&gt;PR #7137&lt;/a&gt;: since Bitwarden clients v2025.11.0, the clients require HTTPS everywhere. So treat localhost over HTTP as a quick peek at most, not a working setup. I didn't try to log in or sync that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Vaultwarden requires HTTPS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Web Crypto only works in a secure context
&lt;/h3&gt;

&lt;p&gt;Bitwarden encrypts your vault in the browser using the Web Crypto API (&lt;code&gt;crypto.subtle&lt;/code&gt;). Browsers only expose that API in a "secure context", which means a page served over HTTPS or from &lt;code&gt;localhost&lt;/code&gt;. The &lt;a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS" rel="noopener noreferrer"&gt;Vaultwarden wiki&lt;/a&gt; says HTTPS is "pretty much required nowadays" for this reason.&lt;/p&gt;

&lt;p&gt;Over plain HTTP from a LAN IP, &lt;code&gt;crypto.subtle&lt;/code&gt; is simply missing. People have reported this error for years. In 2018 it looked like &lt;code&gt;Cannot read property 'importKey' of null&lt;/code&gt; (&lt;a href="https://github.com/dani-garcia/vaultwarden/issues/153" rel="noopener noreferrer"&gt;#153&lt;/a&gt;). A more recent report is "Web Vault stuck on loading with Subtle crypto errors" (&lt;a href="https://github.com/dani-garcia/vaultwarden/issues/5485" rel="noopener noreferrer"&gt;#5485&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it with a reverse proxy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Run Caddy in front of Vaultwarden
&lt;/h3&gt;

&lt;p&gt;The wiki recommends a reverse proxy and suggests Caddy if you don't already have a preference. Vaultwarden's built-in Rocket TLS (&lt;code&gt;ROCKET_TLS=...&lt;/code&gt;) exists, but the wiki advises against it.&lt;/p&gt;

&lt;p&gt;Here is what I ran. Vaultwarden now listens only on 127.0.0.1, and &lt;code&gt;DOMAIN&lt;/code&gt; is set to the HTTPS URL users will actually type:&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;vaultwarden&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;vaultwarden/server:1.37.4&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;vaultwarden&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;DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://vault.example.test"&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;./vw-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:8000:80"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vault.example.test {
  tls internal
  reverse_proxy 127.0.0.1:8000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; caddy &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="nt"&gt;--network&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
  &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;/Caddyfile"&lt;/span&gt;:/etc/caddy/Caddyfile:ro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; caddy_data:/data caddy:2-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command depends on a few things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--restart unless-stopped&lt;/code&gt; makes Caddy come back after a reboot, like Vaultwarden does. My original test run didn't have it, so add it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--network host&lt;/code&gt; means Caddy binds ports 80 and 443 directly on the host, so nothing else may be using them (&lt;code&gt;sudo ss -tlnp | grep -E ':(80|443) '&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vault.example.test&lt;/code&gt; has to resolve to the server. On a test box, add a line like &lt;code&gt;127.0.0.1 vault.example.test&lt;/code&gt; to &lt;code&gt;/etc/hosts&lt;/code&gt;. On other client machines, use the server's LAN IP instead. On a real server, create a DNS A or AAAA record for your domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wiki's &lt;a href="https://github.com/dani-garcia/vaultwarden/wiki/Using-Docker-Compose" rel="noopener noreferrer"&gt;Using Docker Compose&lt;/a&gt; page shows a tidier layout for a real server. It runs Caddy as a second service in the same compose file, proxies to &lt;code&gt;vaultwarden:80&lt;/code&gt; over the compose network, and drops the &lt;code&gt;ports:&lt;/code&gt; section from Vaultwarden. I tested the &lt;code&gt;docker run&lt;/code&gt; version above, not that one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tls internal&lt;/code&gt; makes Caddy issue a certificate from its own local CA, because my test box has no public domain. On a real server with a real domain, delete the &lt;code&gt;tls internal&lt;/code&gt; line. According to &lt;a href="https://caddyserver.com/docs/automatic-https" rel="noopener noreferrer"&gt;Caddy's docs&lt;/a&gt;, it then gets a Let's Encrypt certificate automatically, as long as ports 80 and 443 are reachable and DNS points at the server. That path needs a public domain, so I couldn't run it here. The &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami analytics guide&lt;/a&gt; shows the same Caddy setup for a real domain.&lt;/p&gt;

&lt;p&gt;Here is the difference in one picture:&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%2F63xxgqusum5yca5mh6bs.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%2F63xxgqusum5yca5mh6bs.webp" alt="Diagram comparing two paths. A browser at https://vault.example.com reaches Caddy on port 443 with a certificate, which is a secure context, and Caddy forwards to 127.0.0.1:8000, where Vaultwarden listens on port 80 in its container. A browser on the LAN at http://server-ip:8000 uses plain HTTP, which is not a secure context, so crypto.subtle is missing. The fixed compose file binds 8000 to 127.0.0.1, which closes that path." width="799" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;With the proxy in place, &lt;code&gt;https://vault.example.test&lt;/code&gt; was a secure context (&lt;code&gt;isSecureContext = true&lt;/code&gt;). Vaultwarden reported the right domain in &lt;code&gt;/api/config&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;$ curl -sk https://vault.example.test/api/config   # needs the /etc/hosts entry above
... "vault": "https://vault.example.test" ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The login page loaded with no crypto errors.&lt;/p&gt;

&lt;p&gt;To check the crypto was really working, I created an account. That's the step that needs &lt;code&gt;crypto.subtle&lt;/code&gt;, because the client derives keys from your master password before sending anything. The server log showed registration succeeding (timestamps trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[response][INFO] (register_verification_email) POST /identity/accounts/register/send-verification-email =&amp;gt; 200 OK
[response][INFO] (register_finish) POST /identity/accounts/register/finish =&amp;gt; 200 OK
&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fika3shepajrsyex6s21z.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%2Fika3shepajrsyex6s21z.webp" alt="Vaultwarden web vault open after creating a new account over HTTPS" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My browser was set to ignore the untrusted local CA. With &lt;code&gt;tls internal&lt;/code&gt; in real use, you'd need to install Caddy's root certificate on every device, which is exactly the hassle the wiki warns about with self-signed certificates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock down your new HTTPS vault
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Turn off open registration
&lt;/h3&gt;

&lt;p&gt;With the fixed compose file above, &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; is still at its default, which is &lt;code&gt;true&lt;/code&gt;. The whole point of this guide is to put the vault on an HTTPS domain, so anyone who can reach the URL can create an account on your server. Once you've created your own account (and any for family or team members), disable sign-ups:&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;DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://vault.example.test"&lt;/span&gt;
      &lt;span class="na"&gt;SIGNUPS_ALLOWED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply it with &lt;code&gt;docker compose up -d&lt;/code&gt;. To add people later, the &lt;a href="https://github.com/dani-garcia/vaultwarden/blob/main/.env.template" rel="noopener noreferrer"&gt;&lt;code&gt;.env.template&lt;/code&gt;&lt;/a&gt; lists &lt;code&gt;SIGNUPS_DOMAINS_WHITELIST&lt;/code&gt; for allowing only your email domain, or you can invite them from an organization. If you enable the &lt;code&gt;/admin&lt;/code&gt; page to manage users, set a strong &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; first, as the wiki's &lt;a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page" rel="noopener noreferrer"&gt;Enabling admin page&lt;/a&gt; article explains. I didn't re-test with &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; set to false or with the admin page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the HTTP port private
&lt;/h3&gt;

&lt;p&gt;Don't publish Vaultwarden's plain-HTTP port to the network. In the compose file that's host port 8000 (&lt;code&gt;"127.0.0.1:8000:80"&lt;/code&gt;). Keep the &lt;code&gt;127.0.0.1:&lt;/code&gt; prefix so the only way in is through the HTTPS proxy, not &lt;code&gt;http://&amp;lt;server-ip&amp;gt;:8000&lt;/code&gt;. Then add an &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; monitor for the HTTPS URL, so you hear about a broken certificate before your phone does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still seeing the error?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Check the URL and DOMAIN
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Make sure you're really on &lt;code&gt;https://&lt;/code&gt;. Bookmarks and old browser tabs often still point at &lt;code&gt;http://ip:8000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;DOMAIN&lt;/code&gt; to the exact HTTPS URL, including any subpath. If you change hostnames later, update &lt;code&gt;DOMAIN&lt;/code&gt; too (&lt;a href="https://github.com/dani-garcia/vaultwarden/discussions/7715" rel="noopener noreferrer"&gt;discussion #7715&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Mobile apps and self-signed certificates
&lt;/h3&gt;

&lt;p&gt;Mobile apps are pickier than browsers. The wiki says the mobile app needs a properly configured certificate chain, including OCSP stapling if you run your own CA. A long-running Android thread (&lt;a href="https://github.com/dani-garcia/vaultwarden/issues/687" rel="noopener noreferrer"&gt;#687&lt;/a&gt;) and &lt;a href="https://github.com/dani-garcia/vaultwarden/discussions/7544" rel="noopener noreferrer"&gt;discussion #7544&lt;/a&gt; both ended up being certificate problems. The browser worked and the app didn't. I didn't test mobile apps.&lt;/p&gt;

&lt;p&gt;If you can't use a public domain, follow the wiki's &lt;a href="https://github.com/dani-garcia/vaultwarden/wiki/Private-CA-and-self-signed-certs-that-work-with-Chrome" rel="noopener noreferrer"&gt;private CA guide&lt;/a&gt; instead of a one-off self-signed cert, and install the CA on every client.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Running without a public server
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Can I use Vaultwarden without HTTPS?
&lt;/h4&gt;

&lt;p&gt;Not in practice. Over plain HTTP from another machine, the web vault won't load at all, as shown above. Over &lt;code&gt;http://localhost&lt;/code&gt; the page renders, but current Bitwarden clients refuse non-HTTPS server URLs.&lt;/p&gt;

&lt;h4&gt;
  
  
  Do I need a domain to run Vaultwarden on my home network?
&lt;/h4&gt;

&lt;p&gt;No, but a domain makes it much easier. The wiki has a guide for &lt;a href="https://github.com/dani-garcia/vaultwarden/wiki/Running-a-private-vaultwarden-instance-with-Let's-Encrypt-certs" rel="noopener noreferrer"&gt;a private instance with Let's Encrypt certificates&lt;/a&gt; that uses the DNS challenge, so the server never has to be reachable from the internet. It covers a free Duck DNS subdomain or a domain on Cloudflare. Without any domain, you're left with a private CA installed on every device, and the wiki says that method is meant for testing and development only.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I use Nginx Proxy Manager instead of Caddy?
&lt;/h4&gt;

&lt;p&gt;You should be able to. Any reverse proxy that serves a valid certificate and passes WebSockets gives the browser its secure context. I tested Caddy only. In Nginx Proxy Manager, a proxy host to port 80 with a Let's Encrypt certificate and Websockets Support turned on should do the job. The &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt; walks through that setup. Per the &lt;a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-WebSocket-notifications" rel="noopener noreferrer"&gt;WebSocket notifications wiki page&lt;/a&gt;, WebSockets have run on Vaultwarden's main port since v1.29.0, and v1.31.0 removed the old port 3012. You don't need a separate rule for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clients and accounts
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why can't the Bitwarden mobile app connect to my server?
&lt;/h4&gt;

&lt;p&gt;Usually because of the certificate. The browser may accept a chain that the app rejects. Check that your proxy serves the full chain, and see the mobile apps section above.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why can people still sign up after I set SIGNUPS_ALLOWED to false?
&lt;/h4&gt;

&lt;p&gt;People ask this often (for example in &lt;a href="https://github.com/dani-garcia/vaultwarden/discussions/4058" rel="noopener noreferrer"&gt;discussion #4058&lt;/a&gt;), and Vaultwarden's own docs explain two causes. First, if you ever saved settings on the admin page, Vaultwarden wrote a &lt;code&gt;config.json&lt;/code&gt;, and per the wiki, values in that file take precedence over environment variables. Change the setting on the admin page instead. Second, the &lt;code&gt;.env.template&lt;/code&gt; says &lt;code&gt;SIGNUPS_DOMAINS_WHITELIST&lt;/code&gt; lets listed domains register even when &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; is false.&lt;/p&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/vaultwarden-https-required-fix/" rel="noopener noreferrer"&gt;Fix the Vaultwarden "HTTPS Required" Secure Context Error&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>security</category>
      <category>https</category>
    </item>
    <item>
      <title>Uptime Kuma Docker Setup: Install, Alerts and Common Fixes</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:21:35 +0000</pubDate>
      <link>https://dev.to/deploylark/uptime-kuma-docker-setup-install-alerts-and-common-fixes-l83</link>
      <guid>https://dev.to/deploylark/uptime-kuma-docker-setup-install-alerts-and-common-fixes-l83</guid>
      <description>&lt;p&gt;Uptime Kuma is a self-hosted monitor. It checks your sites and services and tells you when they go down. It's also one of the most popular self-hosted tools around. On October 14, 2026, the &lt;a href="https://github.com/louislam/uptime-kuma" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; had about 92,400 stars, and the &lt;a href="https://hub.docker.com/r/louislam/uptime-kuma" rel="noopener noreferrer"&gt;&lt;code&gt;louislam/uptime-kuma&lt;/code&gt; image&lt;/a&gt; had been pulled more than 192 million times from Docker Hub.&lt;/p&gt;

&lt;p&gt;This guide is the setup I actually ran, plus the problems that come up again and again in the project's GitHub issues.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Start &lt;code&gt;louislam/uptime-kuma:2.5.6&lt;/code&gt; with the official compose file. If a reverse proxy goes in front, bind the port to &lt;code&gt;127.0.0.1:3001&lt;/code&gt;, and either way create the admin account right after the first start.&lt;/li&gt;
&lt;li&gt;Version 2 asks you to pick a database first. SQLite is the simplest choice for one small instance. Keep &lt;code&gt;./data&lt;/code&gt; on a local disk, not NFS or SMB.&lt;/li&gt;
&lt;li&gt;Inside the container, &lt;code&gt;localhost&lt;/code&gt; is the container itself. Monitor services on the host through the Docker network gateway IP or &lt;code&gt;host.docker.internal&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A notification only fires for monitors it's attached to, so turn on Default enabled or Apply on all existing monitors.&lt;/li&gt;
&lt;li&gt;Behind nginx, pass the &lt;code&gt;Upgrade&lt;/code&gt;, &lt;code&gt;Connection&lt;/code&gt; and &lt;code&gt;Host&lt;/code&gt; headers, or the WebSocket handshake fails with a 400.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Uptime Kuma with Docker Compose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Start the container
&lt;/h3&gt;

&lt;p&gt;Make a folder and save the official compose file in 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;uptime-kuma &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;uptime-kuma
curl &lt;span class="nt"&gt;-o&lt;/span&gt; compose.yaml https://raw.githubusercontent.com/louislam/uptime-kuma/master/compose.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the time of writing it contains:&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;./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="c1"&gt;# &amp;lt;Host Port&amp;gt;:&amp;lt;Container Port&amp;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;3001:3001"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch that port line. &lt;code&gt;"3001:3001"&lt;/code&gt; publishes the port on all interfaces (0.0.0.0), and Docker-published ports bypass ufw and firewalld rules. Until you create the admin account, anyone who can reach port 3001 can open the setup wizard, pick the database and make themselves admin. You have two ways to deal with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you'll put a reverse proxy in front (see the reverse proxy section), change the line to &lt;code&gt;"127.0.0.1:3001:3001"&lt;/code&gt; so only the proxy on the same host can reach it.&lt;/li&gt;
&lt;li&gt;If you keep the upstream binding, create the admin account right after you start the container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start it and check 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 compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs uptime-kuma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the full first-start log from a fresh, empty &lt;code&gt;./data&lt;/code&gt; (colour codes stripped, nothing else removed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Welcome to Uptime Kuma
Your Node.js version: 22.22.3
2026-10-14T03:55:30Z [SERVER] INFO: Env: production
2026-10-14T03:55:31Z [SERVER] INFO: Uptime Kuma Version: 2.5.6
2026-10-14T03:55:31Z [SERVER] INFO: Loading modules
2026-10-14T03:55:32Z [SERVER] INFO: Creating express and socket.io instance
2026-10-14T03:55:32Z [SERVER] INFO: Server Type: HTTP
2026-10-14T03:55:32Z [SERVER] INFO: Data Dir: ./data/
2026-10-14T03:55:32Z [SETUP-DATABASE] INFO: db-config.json is not found or invalid: ENOENT: no such file or directory, open 'data/db-config.json'
2026-10-14T03:55:32Z [SETUP-DATABASE] INFO: Starting Setup Database
2026-10-14T03:55:32Z [SETUP-DATABASE] INFO: Listening on:
2026-10-14T03:55:32Z [SETUP-DATABASE] INFO: -  http://localhost:3001
2026-10-14T03:55:32Z [SETUP-DATABASE] INFO: -  http://172.17.0.2:3001
2026-10-14T03:55:32Z [SETUP-DATABASE] INFO: Waiting for user action...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ENOENT ... db-config.json&lt;/code&gt; line is expected on a first start, because there's no database config yet. The &lt;code&gt;172.17.0.2&lt;/code&gt; address is the container's own IP and will differ on your machine.&lt;/p&gt;

&lt;p&gt;"Waiting for user action" is normal for version 2. It wants you to pick a database in the browser before it starts properly. Keep &lt;code&gt;./data&lt;/code&gt; on a local disk, not on NFS or an SMB share (see database errors).&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick a database and create the admin account
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;http://&amp;lt;your-server&amp;gt;:3001&lt;/code&gt;. Version 2 asks which database to use: Embedded MariaDB, external MariaDB/MySQL, or SQLite.&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%2Fmjbsy15mrquzhg57mvjq.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%2Fmjbsy15mrquzhg57mvjq.webp" alt="Uptime Kuma 2 first-start screen asking which database to use: Embedded MariaDB, MariaDB/MySQL or SQLite" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I picked SQLite. For a single small instance it's the simplest option, and everything lives in &lt;code&gt;./data/kuma.db&lt;/code&gt;. Setup took about 15 seconds, and then the page asked me to create an admin account:&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%2Feq9gkd0ov825ox239nz7.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%2Feq9gkd0ov825ox239nz7.webp" alt="Uptime Kuma create admin account form with username and password fields" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This guide covers SQLite only. I didn't run the two MariaDB options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a monitor and a notification
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Add an HTTP monitor
&lt;/h3&gt;

&lt;p&gt;Click Add New Monitor, keep the type as HTTP(s), and fill in a name and a URL. For testing I ran a throwaway nginx container on port 8081 and used a 20-second interval with Retries set to 0, so I could watch the state change quickly. In production, leave retries at 1 or more, or a single dropped packet will page you.&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%2Fk9b4o08pucff65fp1vpx.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%2Fk9b4o08pucff65fp1vpx.webp" alt="Uptime Kuma dashboard with the HTTP test monitor showing Up" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I stopped the nginx container. The next check went red with &lt;code&gt;connect ECONNREFUSED 172.18.0.1:8081&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnmdwl1vka341q7w4idu.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%2Fvnmdwl1vka341q7w4idu.webp" alt="The same Uptime Kuma monitor showing Down after the nginx test container was stopped" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Watch out for Docker networking here. Inside the container, &lt;code&gt;localhost&lt;/code&gt; is the container itself. To monitor something that runs on the host, you need an address that points at the host. &lt;code&gt;172.18.0.1&lt;/code&gt; is the gateway of my compose project's network, and yours will very likely differ. Docker's default bridge is usually &lt;code&gt;172.17.0.1&lt;/code&gt;, and each compose project gets its own subnet. Find yours 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 network inspect uptime-kuma_default &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{(index .IPAM.Config 0).Gateway}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;uptime-kuma_default&lt;/code&gt; is &lt;code&gt;&amp;lt;project folder name&amp;gt;_default&lt;/code&gt;.) A more portable option, per &lt;a href="https://docs.docker.com/reference/compose-file/services/#extra_hosts" rel="noopener noreferrer"&gt;Docker's compose reference&lt;/a&gt;, is to add this to the service in &lt;code&gt;compose.yaml&lt;/code&gt; and use &lt;code&gt;host.docker.internal&lt;/code&gt; as the hostname in your monitors and webhooks:&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;extra_hosts&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;host.docker.internal:host-gateway"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My test used the gateway IP. I didn't repeat it with &lt;code&gt;host.docker.internal&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attach the notification to your monitors
&lt;/h3&gt;

&lt;p&gt;Go to Settings, then Notifications, then Set Up Notification. There are dozens of providers, such as Telegram, Discord, SMTP and ntfy. To keep the test self-contained, I used the generic Webhook type and pointed it at a small HTTP listener on the host (&lt;code&gt;http://172.18.0.1:9999/kuma&lt;/code&gt;, using the gateway IP from above), with Request Body set to &lt;code&gt;application/json&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1gpts9v1rbamjmu5qq74.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%2F1gpts9v1rbamjmu5qq74.webp" alt="Uptime Kuma Setup Notification form with the Webhook type selected, Post URL http://172.18.0.1:9999/kuma, the application/json request body preset, and Default enabled and Apply on all existing monitors switched on" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Test button worked straight away. My listener got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"heartbeat":null,"monitor":null,"msg":"Local webhook Testing"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part that caught me out. The first time, I saved the notification without turning on Default enabled or Apply on all existing monitors. The test message arrived, but when the monitor went down, nothing was sent. A notification only fires for monitors it's attached to. To fix it, edit the monitor and tick the notification, or re-save the notification with Apply on all existing monitors turned on.&lt;/p&gt;

&lt;p&gt;After that, both transitions arrived (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"heartbeat":{"monitorID":1,"status":1,"msg":"200 - OK", ... "retries":0, ...},"monitor":{"name":"demo-site", ...}}
{"heartbeat":{"monitorID":1,"status":0,"msg":"connect ECONNREFUSED 172.18.0.1:8081", ... "retries":1, ...}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't be confused by &lt;code&gt;"retries":1&lt;/code&gt; in the down heartbeat. The monitor's Retries setting really was 0 (I checked the &lt;code&gt;maxretries&lt;/code&gt; column in &lt;code&gt;kuma.db&lt;/code&gt;). The &lt;code&gt;retries&lt;/code&gt; field in the payload is a per-heartbeat failure counter, not the configured setting.&lt;/p&gt;

&lt;p&gt;Uptime Kuma sends a notification only when the state changes (up to down, or down to up), not on every failed check. If you want repeat alerts while something stays down, use the resend setting in the monitor form. I left it at 0, which means off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put Uptime Kuma behind a reverse proxy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pass the WebSocket headers
&lt;/h3&gt;

&lt;p&gt;Uptime Kuma's UI talks to the server over Socket.IO. I put nginx in front with a bare &lt;code&gt;proxy_pass&lt;/code&gt; and nothing else. On the surface nothing looks wrong. The login page still renders:&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%2Ffwdfj4w7ed8tvkn6fdda.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%2Ffwdfj4w7ed8tvkn6fdda.webp" alt="Uptime Kuma login page loaded through nginx with a bare proxy_pass, looking normal even though the WebSocket handshake fails" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the browser console showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WebSocket connection to 'ws://localhost:8090/socket.io/?EIO=4&amp;amp;transport=websocket&amp;amp;sid=...' failed: Error during WebSocket handshake: Unexpected response code: 400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In 2.5.6 the client fell back to HTTP long-polling, so I could still log in. It's slower and less reliable, though. People with more monitors or stricter proxies end up with the "Cannot connect to the socket server" reload loop described in &lt;a href="https://github.com/louislam/uptime-kuma/issues/3884" rel="noopener noreferrer"&gt;#3884&lt;/a&gt; and &lt;a href="https://github.com/louislam/uptime-kuma/issues/958" rel="noopener noreferrer"&gt;#958&lt;/a&gt;. This is the full nginx server block that fixed the 400 in my test (plain HTTP on port 8091, because the lab had no domain):&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;8091&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://127.0.0.1:3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&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;Connection&lt;/span&gt; &lt;span class="s"&gt;"upgrade"&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="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;On a real server, put the &lt;code&gt;location / { ... }&lt;/code&gt; part inside your existing TLS server block (the one with &lt;code&gt;listen 443 ssl;&lt;/code&gt;, &lt;code&gt;server_name status.example.com;&lt;/code&gt; and your &lt;code&gt;ssl_certificate&lt;/code&gt; lines) instead of using &lt;code&gt;listen 8091&lt;/code&gt;. I tested only the plain-HTTP block above. If you proxy other apps from the same nginx, the common pattern is a &lt;code&gt;map $http_upgrade $connection_upgrade { default upgrade; '' close; }&lt;/code&gt; block in the &lt;code&gt;http&lt;/code&gt; context and &lt;code&gt;proxy_set_header Connection $connection_upgrade;&lt;/code&gt;. With a proxy in front, also bind the container to &lt;code&gt;127.0.0.1:3001&lt;/code&gt; as described above.&lt;/p&gt;

&lt;p&gt;Prefer a web UI to nginx config files? The &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt; covers the same job, including the Websockets Support toggle this case needs. The &lt;a href="https://deploylark.com/posts/vaultwarden-https-required-fix/" rel="noopener noreferrer"&gt;Vaultwarden HTTPS guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami analytics guide&lt;/a&gt; use the same &lt;code&gt;127.0.0.1&lt;/code&gt; binding behind Caddy, and both make good first monitors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forward the Host header
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header Host $host&lt;/code&gt; matters too. Since 1.23.9, Uptime Kuma checks that the WebSocket &lt;code&gt;Origin&lt;/code&gt; matches the &lt;code&gt;Host&lt;/code&gt;. In &lt;a href="https://github.com/louislam/uptime-kuma/issues/4210" rel="noopener noreferrer"&gt;#4210&lt;/a&gt;, the maintainer's recommended fix is to forward the original Host header (&lt;code&gt;ProxyPreserveHost on&lt;/code&gt; for Apache). Setting &lt;code&gt;UPTIME_KUMA_WS_ORIGIN_CHECK=bypass&lt;/code&gt; also makes the error go away, but he says plainly that it's "not a good idea, since it is a part of security fix." I didn't reproduce the origin-mismatch error itself, because the 2.5.6 client fell back to polling instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix common Uptime Kuma errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Container and port errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  "Bind for 0.0.0.0:3001 failed: port is already allocated"
&lt;/h4&gt;

&lt;p&gt;I reproduced this by starting a second copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker: Error response from daemon: driver failed programming external connectivity on endpoint kuma-second (...): Bind for 0.0.0.0:3001 failed: port is already allocated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another container or process already has port 3001. Either stop it (&lt;code&gt;docker ps&lt;/code&gt; shows what's using it) or change the host side of the mapping, for example &lt;code&gt;"3002:3001"&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Permission denied on &lt;code&gt;/app/data&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;Reports like &lt;a href="https://github.com/louislam/uptime-kuma/issues/4370" rel="noopener noreferrer"&gt;#4370&lt;/a&gt; (&lt;code&gt;EACCES: permission denied, mkdir 'data/upload'&lt;/code&gt;) come from bind-mounting a folder the container user can't write to, often on NAS shares or in rootless setups. The usual fix is to make sure the folder exists and is writable, or to use a named volume. I didn't hit this with the default compose file running as root.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database and network errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Data on NFS or network shares, "database is locked"
&lt;/h4&gt;

&lt;p&gt;Keep the SQLite file off NFS. In &lt;a href="https://github.com/louislam/uptime-kuma/issues/2668" rel="noopener noreferrer"&gt;#2668&lt;/a&gt; a collaborator points to SQLite's own &lt;a href="https://www.sqlite.org/howtocorrupt.html" rel="noopener noreferrer"&gt;"How To Corrupt"&lt;/a&gt; page, and the wiki now says network filesystems aren't supported. Use a local volume.&lt;/p&gt;

&lt;p&gt;If you see &lt;code&gt;SQLITE_BUSY: database is locked&lt;/code&gt; after upgrading to 2.2.0 or later, read &lt;a href="https://github.com/louislam/uptime-kuma/issues/7289" rel="noopener noreferrer"&gt;#7289&lt;/a&gt;. According to that thread, the maintainer added &lt;code&gt;UPTIME_KUMA_SQLITE_SINGLE_CONNECTION=true&lt;/code&gt; in 2.3.0 to bring back the older single-connection behaviour, and slow storage (the thread mentions eMMC) makes the problem worse. I didn't hit SQLITE_BUSY on my test box.&lt;/p&gt;

&lt;h4&gt;
  
  
  Lots of "timeout of 48000ms exceeded" while the site is up
&lt;/h4&gt;

&lt;p&gt;This is one of the most-commented issues (&lt;a href="https://github.com/louislam/uptime-kuma/issues/275" rel="noopener noreferrer"&gt;#275&lt;/a&gt;, 100+ comments). In several reports the cause was DNS on the Uptime Kuma host, not the target. One commenter fixed it by pointing the container straight at a local DNS server, and a maintainer replied that a DNS cache was worth adding. Retries also help, because one slow lookup then won't flip the monitor to down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrade from Uptime Kuma 1.x
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Back up before you change the tag
&lt;/h3&gt;

&lt;p&gt;If you're on &lt;code&gt;louislam/uptime-kuma:1&lt;/code&gt;, read the &lt;a href="https://github.com/louislam/uptime-kuma/issues/4500" rel="noopener noreferrer"&gt;v2.0 release notes&lt;/a&gt; before changing the tag. Per those notes, the first start runs a data migration that can take a long time on big databases. Back up &lt;code&gt;./data&lt;/code&gt; first. With SQLite, stop the container so the database file isn't being written while you copy it:&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 stop
&lt;span class="nb"&gt;tar &lt;/span&gt;czf kuma-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tgz data
docker compose start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To restore, stop the container and extract the archive in place of &lt;code&gt;./data&lt;/code&gt;. This article doesn't walk through a full 1.x to 2.x migration, and I didn't restore from this backup.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Access and setup
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I reset a forgotten Uptime Kuma password?
&lt;/h4&gt;

&lt;p&gt;Run the reset tool inside the container. The &lt;a href="https://github.com/louislam/uptime-kuma/wiki/Reset-Password-via-CLI" rel="noopener noreferrer"&gt;wiki&lt;/a&gt; documents the interactive version, &lt;code&gt;npm run reset-password&lt;/code&gt;, which asks for the new password twice. With this compose file that's &lt;code&gt;docker compose exec uptime-kuma npm run reset-password&lt;/code&gt;. I tested the non-interactive form on 2.5.6, &lt;code&gt;npm run reset-password -- --new-password='...'&lt;/code&gt;, and it printed "Password reset successfully." The underscore spelling &lt;code&gt;--new_password&lt;/code&gt; from older examples failed with "readline was closed". The password can end up in your shell history this way, and the tool warns you about it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does Uptime Kuma show ECONNREFUSED for a service on localhost?
&lt;/h4&gt;

&lt;p&gt;Because &lt;code&gt;localhost&lt;/code&gt; inside the container is Uptime Kuma itself, as the reporter in &lt;a href="https://github.com/louislam/uptime-kuma/issues/4517" rel="noopener noreferrer"&gt;#4517&lt;/a&gt; worked out. Use the Docker gateway IP or &lt;code&gt;host.docker.internal&lt;/code&gt;, as shown in the monitor section.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does Uptime Kuma support PostgreSQL?
&lt;/h4&gt;

&lt;p&gt;Not as of 2.5.6. The setup screen offers Embedded MariaDB, external MariaDB/MySQL and SQLite. PostgreSQL support is still an open feature request (&lt;a href="https://github.com/louislam/uptime-kuma/issues/959" rel="noopener noreferrer"&gt;#959&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerts and automation
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why do test notifications work but down alerts never arrive?
&lt;/h4&gt;

&lt;p&gt;In my test, the cause was a notification that wasn't attached to the monitor. Edit the monitor and tick the notification, or re-save the notification with Apply on all existing monitors. Also check that the notification service was reachable when the monitor went down. In &lt;a href="https://github.com/louislam/uptime-kuma/issues/6046" rel="noopener noreferrer"&gt;#6046&lt;/a&gt;, a user found that Uptime Kuma doesn't retry a notification if the service is unreachable at that moment, so that alert is simply lost.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can Uptime Kuma monitor Docker containers?
&lt;/h4&gt;

&lt;p&gt;Yes, with the Docker Container monitor type. The &lt;a href="https://github.com/louislam/uptime-kuma/wiki/How-to-Monitor-Docker-Containers" rel="noopener noreferrer"&gt;wiki&lt;/a&gt; says to mount &lt;code&gt;/var/run/docker.sock&lt;/code&gt; into the container, and warns that this gives Uptime Kuma full control over the Docker daemon. It recommends against exposing Uptime Kuma to the internet when you use it. A socket proxy that allows only read access is a common alternative (&lt;a href="https://github.com/louislam/uptime-kuma/issues/4783" rel="noopener noreferrer"&gt;#4783&lt;/a&gt;). I didn't test this monitor type.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does Uptime Kuma have an API?
&lt;/h4&gt;

&lt;p&gt;Not an official REST API. The feature request (&lt;a href="https://github.com/louislam/uptime-kuma/issues/118" rel="noopener noreferrer"&gt;#118&lt;/a&gt;) is still open, and in March 2026 a collaborator pointed people to the internal Socket.IO API and third-party wrappers instead. If you build on either, expect changes between releases.&lt;/p&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma Docker Setup: Install, Alerts and Common Fixes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>monitoring</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stirling-PDF Docker Setup: Login, OCR Languages and Updates</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:16:14 +0000</pubDate>
      <link>https://dev.to/deploylark/stirling-pdf-docker-setup-login-ocr-languages-and-updates-5ei9</link>
      <guid>https://dev.to/deploylark/stirling-pdf-docker-setup-login-ocr-languages-and-updates-5ei9</guid>
      <description>&lt;p&gt;Stirling-PDF is a self-hosted toolbox for PDFs. Merge, split, compress, sign, redact, convert and OCR run on your own server, so the files never go to an online PDF site. On October 17, 2026, the &lt;a href="https://github.com/Stirling-Tools/Stirling-PDF" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; had about 94,300 stars, and the &lt;code&gt;stirlingtools/stirling-pdf&lt;/code&gt; image had about 16.3 million pulls on Docker Hub, plus 22.5 million under the older &lt;code&gt;frooodle/s-pdf&lt;/code&gt; name, which still gets new releases.&lt;/p&gt;

&lt;p&gt;This guide runs Stirling-PDF 3.2.0 with Docker Compose and fixes the parts the quick start leaves to you. The default login is shown on the login page to anyone who opens it, extra OCR languages need a volume, and uploads through nginx fail above 1 MB until you change one setting. I ran every step on a Debian 13 test server with Docker 29.9.1, OCR'd scans I generated in English and Spanish, and upgraded a second install from 3.1.0 to 3.2.0.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Use the official compose file with a pinned tag such as &lt;code&gt;docker.stirlingpdf.com/stirlingtools/stirling-pdf:3.2.0&lt;/code&gt;, publish the port on &lt;code&gt;127.0.0.1:8080&lt;/code&gt;, and keep &lt;code&gt;mem_limit: 4g&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A fresh install creates &lt;code&gt;admin&lt;/code&gt; with the password &lt;code&gt;stirling&lt;/code&gt; and prints both on the login page. Set &lt;code&gt;SECURITY_INITIALLOGIN_USERNAME&lt;/code&gt; and &lt;code&gt;SECURITY_INITIALLOGIN_PASSWORD&lt;/code&gt; before the first start, or log in and change the password right away.&lt;/li&gt;
&lt;li&gt;The standard image can OCR English, German, French, Portuguese and Simplified Chinese. For any other language, put its &lt;code&gt;.traineddata&lt;/code&gt; file in the folder mounted at &lt;code&gt;/usr/share/tessdata&lt;/code&gt; and restart.&lt;/li&gt;
&lt;li&gt;Behind nginx, set &lt;code&gt;client_max_body_size 2000M&lt;/code&gt;. To update, change the tag, then run &lt;code&gt;docker compose pull&lt;/code&gt; and &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Stirling-PDF runs and which image to pick
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's in the container
&lt;/h3&gt;

&lt;p&gt;Stirling-PDF is one container. It holds a Java web app plus the command-line tools that do the heavy work. The diagram shows the setup this guide builds:&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%2F5lau7a5u9v7yilg4qkpl.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%2F5lau7a5u9v7yilg4qkpl.webp" alt="Diagram of the Stirling-PDF setup on one Docker host. Your browser, or curl with an API token, reaches the server through an SSH tunnel or a reverse proxy with HTTPS and client_max_body_size 2000M. The proxy connects to the stirling-pdf 3.2.0 container, published on 127.0.0.1:8080 only. The container runs the Java app with login on, OCRmyPDF 17.13.0 with Tesseract 5.3.4, and LibreOffice 26.2.6.3 started on first use, with mem_limit 4g sizing the Java heap. Below it, the ./stirling-data bind mounts hold configs (settings.yml, database, keys), storage, tessdata for extra OCR languages, logs, customFiles and pipeline." width="799" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 3.2.0 container logs its tool versions at startup. Mine listed OpenJDK 25.0.4, OCRmyPDF 17.13.0, Tesseract 5.3.4, LibreOffice 26.2.6.3, Ghostscript 10.08.0, qpdf 12.4.2 and calibre 9.15.0. LibreOffice doesn't start until the first Office conversion, so it uses no memory until you need it. One feature is off out of the box. The log said &lt;code&gt;Missing dependency: rar - Disabling group: rar (Affected features: PDF To Cbr)&lt;/code&gt;, which matches the docs.&lt;/p&gt;

&lt;p&gt;Settings, the user database and generated keys go in &lt;code&gt;/configs&lt;/code&gt;. On first start the app also creates &lt;code&gt;credential-encryption.key&lt;/code&gt; there and logs a warning to back it up, because losing it makes stored integration secrets unrecoverable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Standard, fat or ultra-lite
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.stirlingpdf.com/Server/Install/Docker/" rel="noopener noreferrer"&gt;Docker install docs&lt;/a&gt; list three images:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Login&lt;/th&gt;
&lt;th&gt;What's included&lt;/th&gt;
&lt;th&gt;Suggested memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3.2.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;OCR plus Office, HTML and ebook conversion&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3.2.0-fat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The same plus a bundled AI engine, off until you turn it on&lt;/td&gt;
&lt;td&gt;6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3.2.0-ultra-lite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Core PDF tools only, no OCR or conversions&lt;/td&gt;
&lt;td&gt;2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use the standard image. It's the one I tested end to end. The compressed download on Docker Hub was 1.17 GB for &lt;code&gt;3.2.0&lt;/code&gt; and 394 MB for &lt;code&gt;3.2.0-ultra-lite&lt;/code&gt;. On disk they took 3.58 GB and 1.04 GB.&lt;/p&gt;

&lt;p&gt;Ultra-lite is lighter, and my test showed what you give up. It used 357 MiB of RAM after starting, against 795 MiB to 1.08 GiB for the standard image. But it has no login at all. The browser went straight to the editor, API calls worked without credentials, and the OCR endpoint answered &lt;code&gt;403 This endpoint is disabled&lt;/code&gt;. Only run it on a network you trust.&lt;/p&gt;

&lt;p&gt;The docs use &lt;code&gt;latest&lt;/code&gt; in their examples, but &lt;code&gt;latest&lt;/code&gt; moves with every release. Pin a version tag instead. All three tags above exist on &lt;code&gt;docker.stirlingpdf.com&lt;/code&gt;, Docker Hub and &lt;code&gt;ghcr.io&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Compose setup
&lt;/h2&gt;

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

&lt;p&gt;This is the compose file from the install docs with three changes. The tag is pinned to the version I tested, the port listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only, and the first admin account comes from a &lt;code&gt;.env&lt;/code&gt; file. Create the folder and the &lt;code&gt;.env&lt;/code&gt; 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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/stirling-pdf/stirling-data/tessdata &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/stirling-pdf
&lt;span class="nb"&gt;umask &lt;/span&gt;077
&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;
SECURITY_INITIALLOGIN_USERNAME=pdfadmin
SECURITY_INITIALLOGIN_PASSWORD=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 18 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'/+='&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;umask &lt;/span&gt;022
&lt;span class="nb"&gt;cat&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;umask 077&lt;/code&gt; makes &lt;code&gt;.env&lt;/code&gt; readable only by you. Save the generated password in your password manager. Then save this as &lt;code&gt;compose.yaml&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;stirling-pdf&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.stirlingpdf.com/stirlingtools/stirling-pdf:3.2.0&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;stirling-pdf&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:8080:8080"&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;SECURITY_INITIALLOGIN_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${SECURITY_INITIALLOGIN_USERNAME}&lt;/span&gt;
      &lt;span class="na"&gt;SECURITY_INITIALLOGIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${SECURITY_INITIALLOGIN_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;./stirling-data/configs:/configs&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./stirling-data/logs:/logs&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./stirling-data/customFiles:/customFiles&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./stirling-data/pipeline:/pipeline&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./stirling-data/storage:/storage&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./stirling-data/tessdata:/usr/share/tessdata&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4g&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;Keep &lt;code&gt;mem_limit&lt;/code&gt;. The image sizes the Java heap from it. My container logged &lt;code&gt;Detected container memory: 4096MB&lt;/code&gt; and let Java use up to 70% of that. Without a limit, the &lt;a href="https://docs.stirlingpdf.com/Server/FAQ/" rel="noopener noreferrer"&gt;server FAQ&lt;/a&gt; says the heap is sized from the host's RAM, which is why people see Stirling-PDF use a lot of memory while idle.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mkdir&lt;/code&gt; creates the &lt;code&gt;tessdata&lt;/code&gt; folder in advance on purpose. When I let Docker create it, the folder belonged to root, so I needed &lt;code&gt;sudo&lt;/code&gt; to copy language files in later. The other folders ended up owned by UID 1000, the user the container runs as. If your files belong to a different user, the install docs say to set &lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  First start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
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:8080/api/v1/info/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pull took 17 seconds on my server. About 19 seconds after &lt;code&gt;up -d&lt;/code&gt;, the status endpoint answered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"version":"3.2.0","status":"UP"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That endpoint needs no login, so it's a good target for an &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; HTTP monitor. Port 8080 now only answers on the server itself. To use the web UI from your computer, open an SSH tunnel with &lt;code&gt;ssh -L 8080:127.0.0.1:8080 you@your-server&lt;/code&gt; and browse to &lt;code&gt;http://localhost:8080&lt;/code&gt;, or put a reverse proxy in front (see below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock down the default login
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What a fresh install shows
&lt;/h3&gt;

&lt;p&gt;Login is on by default in the standard image. When I started a fresh install without the &lt;code&gt;.env&lt;/code&gt; variables, the log said &lt;code&gt;Default admin user created: admin&lt;/code&gt;, and the login page showed this to every visitor:&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%2Fnm2l1c88j6tyin5zxrnk.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%2Fnm2l1c88j6tyin5zxrnk.webp" alt="Stirling-PDF 3.2.0 login page with username and password fields and a Login button. Below them, a box titled Default Login Credentials shows Username: admin and Password: stirling, with the note Please change your password after logging in for the first time." width="500" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The page prints the password, so anyone who reaches it before you change it can log in as admin. Upstream's compose file publishes &lt;code&gt;'8080:8080'&lt;/code&gt; on every interface, and &lt;a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" rel="noopener noreferrer"&gt;Docker's published ports skip ufw rules&lt;/a&gt;, so a host firewall won't save you. That's the reason for the &lt;code&gt;127.0.0.1&lt;/code&gt; binding.&lt;/p&gt;

&lt;p&gt;Logging in as &lt;code&gt;admin&lt;/code&gt; with &lt;code&gt;stirling&lt;/code&gt; first asked whether to send anonymous usage data, then forced a new 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl5hjhbuv0oty3aj11dsq.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%2Fl5hjhbuv0oty3aj11dsq.webp" alt="Stirling-PDF Set Your Password dialog with the message For security reasons, you must change your password on your first login, the line Logged in as: admin, fields for New Password with a minimum of 8 characters and Confirm New Password, and a Change Password button." width="660" height="680"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the change, Stirling-PDF logged me out, and the credentials box was gone from the login page. The &lt;a href="https://docs.stirlingpdf.com/Server/Security/Sign-in-and-Security/" rel="noopener noreferrer"&gt;sign-in docs&lt;/a&gt; add that an account locks for 120 minutes after 5 failed attempts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set your own first admin
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; approach above skips all of that. On a fresh 3.1.0 install with &lt;code&gt;SECURITY_INITIALLOGIN_USERNAME=pdfadmin&lt;/code&gt;, the log said &lt;code&gt;Admin user created: pdfadmin&lt;/code&gt;. The login page had no credentials box, logging in went straight to the editor with no password prompt, and &lt;code&gt;admin&lt;/code&gt; with &lt;code&gt;stirling&lt;/code&gt; got a &lt;code&gt;401&lt;/code&gt;. The docs say both variables are ignored once the database has users, so changing them later does nothing. Change the password in the app instead.&lt;/p&gt;

&lt;p&gt;Don't turn login off unless only you can reach the server. The &lt;a href="https://docs.stirlingpdf.com/Server/" rel="noopener noreferrer"&gt;server overview&lt;/a&gt; puts it plainly for &lt;code&gt;SECURITY_ENABLELOGIN=false&lt;/code&gt;: "Anyone who can reach the server can then use it." With login on, the API is protected too. A &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/api/v1/misc/ocr-pdf&lt;/code&gt; without credentials got a &lt;code&gt;401&lt;/code&gt;. The API description at &lt;code&gt;/v1/api-docs&lt;/code&gt; and the Swagger page answered &lt;code&gt;200&lt;/code&gt; without login, but they only describe the endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add OCR languages
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's bundled, and what a missing language looks like
&lt;/h3&gt;

&lt;p&gt;The standard image ships Tesseract data for Simplified Chinese, English, French, German and Portuguese. &lt;code&gt;tesseract --list-langs&lt;/code&gt; in my container showed &lt;code&gt;chi_sim&lt;/code&gt;, &lt;code&gt;deu&lt;/code&gt;, &lt;code&gt;eng&lt;/code&gt;, &lt;code&gt;fra&lt;/code&gt;, &lt;code&gt;osd&lt;/code&gt; and &lt;code&gt;por&lt;/code&gt;. The ultra-lite image has no OCR.&lt;/p&gt;

&lt;p&gt;I generated an image-only scan of an English invoice and OCR'd it through the API in 2.2 seconds. The invoice number, date and amounts came back right. Then I tried a scanned Spanish letter. Asking for Spanish before it was installed got an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP 400 {"detail":"Invalid OCR languages format: none of the selected languages are valid", ... "errorCode":"E041"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per the docs, the web UI only lists installed languages. Running the Spanish letter with English worked, but every accent was gone, and "señora Núñez" became "sefiora Niifiez".&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a language with the tessdata volume
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.stirlingpdf.com/Server/Operations/OCR-Languages/" rel="noopener noreferrer"&gt;OCR languages docs&lt;/a&gt; describe two ways. The in-app download under Settings, Server, Advanced puts files inside the container, so they disappear when the container is recreated. The volume survives that. Download the &lt;code&gt;.traineddata&lt;/code&gt; file from &lt;a href="https://github.com/tesseract-ocr/tessdata" rel="noopener noreferrer"&gt;tesseract-ocr/tessdata&lt;/a&gt; (larger, more accurate) or tessdata_fast (smaller, faster) and restart:&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;cd&lt;/span&gt; ~/stirling-pdf
curl &lt;span class="nt"&gt;-fL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; stirling-data/tessdata/spa.traineddata &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/tesseract-ocr/tessdata/raw/main/spa.traineddata
docker compose restart
docker &lt;span class="nb"&gt;exec &lt;/span&gt;stirling-pdf tesseract &lt;span class="nt"&gt;--list-langs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file was 18 MB. At startup, the log said &lt;code&gt;Found user-mounted tessdata in /usr/share/tessdata, copying to /usr/share/tesseract-ocr/5/tessdata&lt;/code&gt;, and &lt;code&gt;spa&lt;/code&gt; joined the list. Spanish then showed up in the OCR tool:&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%2F9c1kwgcfpagcz79dknr6.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%2F9c1kwgcfpagcz79dknr6.webp" alt="Stirling-PDF OCR / Cleanup scans tool with the scanned Spanish letter carta-scan-es.pdf open in the viewer. The settings panel shows OCR Mode Auto (skip text layers) and an open Languages list with Chinese, English (checked), French, German, Portuguese and Spanish, plus a link to the setup guide for additional languages." width="800" height="633"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what the two runs stored for the same scan:&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%2Fuqp87cznbcx0dcb0afue.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%2Fuqp87cznbcx0dcb0afue.webp" alt="Terminal capture of the OCR text Stirling-PDF 3.2.0 produced for the same scanned Spanish letter. With languages=eng the lines read Ayuntamiento de Malaga, Notificacion de pago, Estimada sefiora Niifiez, recibo del afio and Oficina de Recaudacion. With languages=spa after adding spa.traineddata they read Málaga, Notificación, señora Núñez, año and Recaudación, with a few remaining errors such as infofrnamos, esfá and pendiehte." width="799" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;spa&lt;/code&gt;, the accents and the ñ came out right, but three words were still wrong. OCR on a noisy scan isn't perfect in any language, so check important documents. If you want every scan OCR'd and filed automatically, &lt;a href="https://deploylark.com/posts/paperless-ngx-docker-compose-ocr/" rel="noopener noreferrer"&gt;Paperless-ngx&lt;/a&gt; uses the same OCRmyPDF and Tesseract tools on each document it imports. Stirling-PDF fits one-off jobs better.&lt;/p&gt;

&lt;p&gt;The volume path is the most common mistake. In a &lt;a href="https://github.com/Stirling-Tools/Stirling-PDF/issues/1657#issuecomment-2277607702" rel="noopener noreferrer"&gt;GitHub issue&lt;/a&gt; where a user had mounted language files at &lt;code&gt;/data/tessdata&lt;/code&gt;, maintainer Frooodle answered: "Your path is wrong, all our docs show it as /usr/share/tessdata". The docs add that files already in Tesseract's folder aren't replaced, and that &lt;code&gt;eng.traineddata&lt;/code&gt; must stay, because English is always needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverse proxy, updates and backups
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Big uploads behind nginx
&lt;/h3&gt;

&lt;p&gt;For HTTPS, put Stirling-PDF behind a reverse proxy that forwards to &lt;code&gt;127.0.0.1:8080&lt;/code&gt;. &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; does that with a free certificate, and the &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami guide&lt;/a&gt; shows the same &lt;code&gt;127.0.0.1&lt;/code&gt; pattern behind Caddy.&lt;/p&gt;

&lt;p&gt;With nginx, check the upload size first. nginx accepts 1 MB request bodies by default, and PDFs are often bigger. I sent a 2.6 MB four-page scan through nginx 1.30.6 with a plain &lt;code&gt;proxy_pass&lt;/code&gt; and got &lt;code&gt;413 Request Entity Too Large&lt;/code&gt;. The nginx log said &lt;code&gt;client intended to send too large body: 2602334 bytes&lt;/code&gt;. The &lt;a href="https://docs.stirlingpdf.com/Server/Production/" rel="noopener noreferrer"&gt;production checklist&lt;/a&gt; has the settings that fixed it:&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="c1"&gt;# listen 443 ssl, server_name and certificates as usual&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;2000M&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://127.0.0.1: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-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="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;1800s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="s"&gt;1800s&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;The same request then returned &lt;code&gt;200&lt;/code&gt; in 2.2 seconds with all four pages OCR'd. 2000 MB matches Stirling-PDF's own default upload limit. The long timeouts and &lt;code&gt;proxy_buffering off&lt;/code&gt; are for slow jobs. Nginx Proxy Manager already sets &lt;code&gt;client_max_body_size 2000m&lt;/code&gt; in the main &lt;code&gt;nginx.conf&lt;/code&gt; of its &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/nginx.conf" rel="noopener noreferrer"&gt;2.16.0 image&lt;/a&gt;, so this limit mostly bites hand-written nginx configs. The checklist also says to set &lt;code&gt;SYSTEM_FRONTENDURL&lt;/code&gt; to your public address, because invite emails and share links use it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update and back up
&lt;/h3&gt;

&lt;p&gt;Back up before you update. The production checklist names the folders. &lt;code&gt;configs&lt;/code&gt; holds settings, keys and the built-in database, which also writes a nightly backup to &lt;code&gt;configs/backup/db/&lt;/code&gt;. &lt;code&gt;storage&lt;/code&gt; holds files users saved on the server. A tarball is enough:&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;cd&lt;/span&gt; ~/stirling-pdf
&lt;span class="nb"&gt;sudo tar &lt;/span&gt;czf stirling-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tgz stirling-data/configs stirling-data/storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then change the tag and recreate the container. I tested this on a second install that started on 3.1.0 with its own admin account:&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="s1"&gt;'s/stirling-pdf:3.1.0/stirling-pdf:3.2.0/'&lt;/span&gt; compose.yaml
docker compose pull
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:8080/api/v1/info/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;22 seconds later the status said &lt;code&gt;3.2.0&lt;/code&gt;, and the admin from the first start could still log in. Read the &lt;a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; before each bump. Releases come fast: 3.0.0 came out on September 24, 2026, 3.1.0 on October 5 and 3.2.0 on October 15. Coming from V1, read the upgrade guide the install docs link to first. If you'd rather deploy from a dashboard, the same compose file works as a Docker Compose resource in &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;
  
  
  Login and access
&lt;/h3&gt;

&lt;h4&gt;
  
  
  What is the default username and password for Stirling-PDF?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;admin&lt;/code&gt; and &lt;code&gt;stirling&lt;/code&gt;. A fresh standard image creates that account and shows it on the login page until you change the password, which the first login forces. To skip the default account, set &lt;code&gt;SECURITY_INITIALLOGIN_USERNAME&lt;/code&gt; and &lt;code&gt;SECURITY_INITIALLOGIN_PASSWORD&lt;/code&gt; before the first start. They only apply while the user database is empty.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I run Stirling-PDF without a login?
&lt;/h4&gt;

&lt;p&gt;Yes. Set &lt;code&gt;SECURITY_ENABLELOGIN=false&lt;/code&gt;, or use the ultra-lite image, which has no login at all. Then anyone who can reach the port can use every tool and the API, as my ultra-lite test showed. Keep it on &lt;code&gt;127.0.0.1&lt;/code&gt; or a private network if you do this.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is Stirling-PDF free to self-host?
&lt;/h4&gt;

&lt;p&gt;Yes. The repository is MIT licensed, with some folders, such as the proprietary and SaaS code, under their own licenses. Per the &lt;a href="https://docs.stirlingpdf.com/Server/Licensing/Plans-and-Licences/" rel="noopener noreferrer"&gt;plans page&lt;/a&gt;, a self-hosted server runs on the Free plan, with all PDF tools, the REST API and OAuth2 single sign-on, for up to 5 users. Team and Enterprise add more users and features like an external database.&lt;/p&gt;

&lt;h3&gt;
  
  
  OCR and performance
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why doesn't my OCR language show up in Stirling-PDF?
&lt;/h4&gt;

&lt;p&gt;The &lt;code&gt;.traineddata&lt;/code&gt; file isn't where the app looks. Mount your folder at &lt;code&gt;/usr/share/tessdata&lt;/code&gt; inside the container, restart, and check with &lt;code&gt;docker exec stirling-pdf tesseract --list-langs&lt;/code&gt;. If the API returns &lt;code&gt;Invalid OCR languages format&lt;/code&gt;, the language you asked for isn't installed.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does Stirling-PDF use so much RAM?
&lt;/h4&gt;

&lt;p&gt;Java sizes its heap from the memory it sees. Set &lt;code&gt;mem_limit&lt;/code&gt; in compose or &lt;code&gt;--memory&lt;/code&gt; with &lt;code&gt;docker run&lt;/code&gt;. With a 4 GB limit, my standard container used between 795 MiB and 1.08 GiB while idle. If that's too much and you don't need OCR or conversions, ultra-lite used 357 MiB.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why do large uploads fail behind a reverse proxy?
&lt;/h4&gt;

&lt;p&gt;The proxy is rejecting the upload. nginx allows 1 MB by default and returned &lt;code&gt;413 Request Entity Too Large&lt;/code&gt; for my 2.6 MB scan. Set &lt;code&gt;client_max_body_size 2000M&lt;/code&gt; in the server block and reload nginx. Nginx Proxy Manager already allows 2000 MB.&lt;/p&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/stirling-pdf-docker/" rel="noopener noreferrer"&gt;Stirling-PDF Docker Setup: Login, OCR Languages and Updates&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>pdf</category>
      <category>ocr</category>
    </item>
    <item>
      <title>How to Self-Host Umami Analytics with Docker and PostgreSQL</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:10:53 +0000</pubDate>
      <link>https://dev.to/deploylark/how-to-self-host-umami-analytics-with-docker-and-postgresql-3mic</link>
      <guid>https://dev.to/deploylark/how-to-self-host-umami-analytics-with-docker-and-postgresql-3mic</guid>
      <description>&lt;p&gt;Umami is a small, privacy-friendly alternative to Google Analytics. You can run it yourself with two containers: the app and a PostgreSQL database. It has a big following among self-hosters. On October 14, 2026, the &lt;a href="https://github.com/umami-software/umami" rel="noopener noreferrer"&gt;Umami repository&lt;/a&gt; had about 39,400 GitHub stars.&lt;/p&gt;

&lt;p&gt;PostgreSQL is the database to use. When a user tried Umami 3 on MySQL in &lt;a href="https://github.com/umami-software/umami/issues/3859" rel="noopener noreferrer"&gt;#3859&lt;/a&gt;, maintainer Mike Cao answered: "v3 does not run on MySQL".&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;ghcr.io/umami-software/umami:3.4.0&lt;/code&gt; with &lt;code&gt;postgres:15.19-alpine&lt;/code&gt;. Umami v3 doesn't run on MySQL.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;, &lt;code&gt;APP_SECRET&lt;/code&gt; and &lt;code&gt;TWO_FACTOR_ENCRYPTION_KEY&lt;/code&gt; in a &lt;code&gt;.env&lt;/code&gt; file. Choose the database password once, because changing it later breaks the app.&lt;/li&gt;
&lt;li&gt;Bind the port to &lt;code&gt;127.0.0.1:3000&lt;/code&gt; and put Caddy in front for HTTPS.&lt;/li&gt;
&lt;li&gt;The default login is admin / umami. Change the password before you do anything else, then add your website and paste the tracking script into your site's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prepare the secrets and the compose file
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create the secrets
&lt;/h3&gt;

&lt;p&gt;Don't use the placeholder passwords from the example file. Put real ones in a &lt;code&gt;.env&lt;/code&gt; file next to the compose 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;mkdir &lt;/span&gt;umami &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;umami
&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;
POSTGRES_PASSWORD=&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;
APP_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;
TWO_FACTOR_ENCRYPTION_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;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chmod 600&lt;/code&gt; keeps the secrets readable only by your user. The values are hex on purpose. Hex has no quotes, &lt;code&gt;$&lt;/code&gt; or &lt;code&gt;@&lt;/code&gt; characters that could break the &lt;code&gt;DATABASE_URL&lt;/code&gt; or a shell command later.&lt;/p&gt;

&lt;p&gt;Choose &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; once and keep it. Changing it later breaks the app (see the 28P01 error).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TWO_FACTOR_ENCRYPTION_KEY&lt;/code&gt; is newer. Older guides leave it out, and people who then turned on 2FA found their instance blocked by "TWO_FACTOR_ENCRYPTION_KEY is missing or invalid" (&lt;a href="https://github.com/umami-software/umami/issues/4443" rel="noopener noreferrer"&gt;#4443&lt;/a&gt;). Set it from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write the compose file
&lt;/h3&gt;

&lt;p&gt;This is the &lt;a href="https://github.com/umami-software/umami/blob/master/docker-compose.yml" rel="noopener noreferrer"&gt;official docker-compose.yml&lt;/a&gt; with the hard-coded secrets replaced by values from &lt;code&gt;.env&lt;/code&gt;, and one more change: the port is bound to &lt;code&gt;127.0.0.1&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;umami&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/umami-software/umami:3.4.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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami&lt;/span&gt;
      &lt;span class="na"&gt;APP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_SECRET}&lt;/span&gt;
      &lt;span class="na"&gt;TWO_FACTOR_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${TWO_FACTOR_ENCRYPTION_KEY}&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;init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&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;http://localhost:3000/api/heartbeat"&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;5&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:15.19-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;umami&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;umami&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&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;umami-db-data:/var/lib/postgresql/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;always&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_DB}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;5&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;umami-db-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;127.0.0.1:3000:3000&lt;/code&gt;? Upstream publishes &lt;code&gt;"3000:3000"&lt;/code&gt;, which listens on all interfaces, and Docker-published ports bypass ufw. Umami starts with a default admin / umami login, so until you change it, anyone who can reach port 3000 could log in as admin. Binding to localhost means only the reverse proxy on the same host can reach it.&lt;/p&gt;

&lt;p&gt;Here's what I tested with that binding. I did a first start on a fresh volume with the port bound to &lt;code&gt;127.0.0.1:3900&lt;/code&gt;. The migrations ran, the heartbeat answered on 127.0.0.1, and the port didn't answer on the host's network address. The logs matched the excerpt below. The later steps (logging in, adding a website and tracking a view) ran on upstream's &lt;code&gt;"3000:3000"&lt;/code&gt; binding, not the localhost one. If you keep upstream's binding, change the password before the port is reachable from outside.&lt;/p&gt;

&lt;p&gt;This is the capture from that run, trimmed. The &lt;code&gt;…&lt;/code&gt; lines mark removed output, and the last two lines are my notes on the two &lt;code&gt;curl&lt;/code&gt; results:&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%2Fbdw06xfe73hvs3r6zzu2.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%2Fbdw06xfe73hvs3r6zzu2.webp" alt="Terminal capture from the Umami lab run: docker compose ps shows the db and umami containers healthy with the port published as 127.0.0.1:3900 to 3000, the log shows the database checks passing, 26 migrations found, all migrations applied and the database up to date, and the notes record HTTP 200 from the heartbeat on 127.0.0.1 and no answer (000) on the host network address" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The healthcheck is upstream's, so I kept it. Note that &lt;code&gt;curl&lt;/code&gt; without &lt;code&gt;-f&lt;/code&gt; exits 0 even on an HTTP 500, so it only catches a dead process. With &lt;code&gt;curl -f http://localhost:3000/api/heartbeat&lt;/code&gt;, errors would count too. If you have a large existing database, also give migrations more time before the healthcheck gives up, for example with &lt;code&gt;start_period: 300s&lt;/code&gt; or more &lt;code&gt;retries&lt;/code&gt; (see the check-db error below). I didn't test either change.&lt;/p&gt;

&lt;p&gt;If you plan to upgrade on purpose rather than whenever &lt;code&gt;latest&lt;/code&gt; moves, pin a version tag instead of &lt;code&gt;latest&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Umami and log in
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Run the first start
&lt;/h3&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 logs &lt;span class="nt"&gt;-f&lt;/span&gt; umami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first start, Umami creates the database schema itself. Here's an excerpt from my log on a fresh, empty volume (the &lt;code&gt;umami-1  |&lt;/code&gt; prefixes and the long list of migration folders removed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ DATABASE_URL is defined.
✓ Database connection successful.
✓ Database version check successful.
26 migrations found in prisma/migrations
Applying migration `01_init`
...
Applying migration `26_add_api_key`
All migrations have been successfully applied.
✓ Database is up to date.
▲ Next.js 16.3.4
- Local:         http://localhost:3000
- Network:       http://0.0.0.0:3000
✓ Ready in 0ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On later restarts you'll see &lt;code&gt;No pending migrations to apply.&lt;/code&gt; instead of the &lt;code&gt;Applying migration&lt;/code&gt; lines.&lt;/p&gt;

&lt;p&gt;The heartbeat endpoint is a quick way to check it's up. It also makes a good target for an &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; monitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl localhost:3000/api/heartbeat
{"ok":true}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Change the default password
&lt;/h3&gt;

&lt;p&gt;With the localhost binding, open &lt;code&gt;http://localhost:3000&lt;/code&gt; on the server itself. You can also tunnel it from your laptop with &lt;code&gt;ssh -L 3000:127.0.0.1:3000 you@server&lt;/code&gt; and open &lt;code&gt;http://localhost:3000&lt;/code&gt; there, or set up the HTTPS proxy below first and log in through it. The default login is admin / umami.&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%2Ffgz65dwdhc377weidu4y.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%2Ffgz65dwdhc377weidu4y.webp" alt="Umami 3 login page with username and password fields" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Change the password before you do anything else. The easy way is the dashboard, under Settings, then Profile. I did it through the API, which works the same way. It needs &lt;code&gt;jq&lt;/code&gt; (&lt;code&gt;sudo apt install jq&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;&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/api/auth/login &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"username":"admin","password":"umami"}'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .token&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/api/me/password &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"currentPassword":"umami","newPassword":"&amp;lt;new password&amp;gt;"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returned 200, and after that &lt;code&gt;admin / umami&lt;/code&gt; got &lt;code&gt;401 incorrect-username-password&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add your website and HTTPS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Add the tracking script
&lt;/h3&gt;

&lt;p&gt;In the dashboard, go to Settings, then Websites, then Add website. I used the API instead (&lt;code&gt;POST /api/websites&lt;/code&gt; with &lt;code&gt;{"name":"Test site","domain":"localhost"}&lt;/code&gt;). Umami gives you a website ID.&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%2Fzlu8ynxf46320bxstxz7.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%2Fzlu8ynxf46320bxstxz7.webp" alt="Umami Websites settings page with the Add website button" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Put the script in your site's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.example.com/script.js"&lt;/span&gt;
        &lt;span class="na"&gt;data-website-id=&lt;/span&gt;&lt;span class="s"&gt;"a91472cb-2673-40ad-8940-7a865af4701d"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I served a test page with that tag from a second container and visited it. The beacon request went to &lt;code&gt;/api/send&lt;/code&gt; and the view showed up on the dashboard:&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%2Fe6caz2dbnkniblh5w8mu.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%2Fe6caz2dbnkniblh5w8mu.webp" alt="Umami dashboard for the test site showing its first recorded page view" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Put Caddy in front for HTTPS
&lt;/h3&gt;

&lt;p&gt;The script tag above uses &lt;code&gt;https://analytics.example.com&lt;/code&gt;, and on a real site it has to be HTTPS, because an HTTPS page won't load a script from &lt;code&gt;http://&lt;/code&gt;. The simplest way is &lt;a href="https://caddyserver.com/docs/install" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; on the same host. Per &lt;a href="https://caddyserver.com/docs/automatic-https" rel="noopener noreferrer"&gt;Caddy's docs&lt;/a&gt;, it gets a Let's Encrypt certificate automatically. Point a DNS A or AAAA record for &lt;code&gt;analytics.example.com&lt;/code&gt; at your server, make sure ports 80 and 443 are open, and use this Caddyfile (&lt;code&gt;/etc/caddy/Caddyfile&lt;/code&gt; for the Debian package):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;analytics.example.com {
    reverse_proxy 127.0.0.1:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run &lt;code&gt;sudo systemctl reload caddy&lt;/code&gt;. This is why the compose file binds Umami to &lt;code&gt;127.0.0.1&lt;/code&gt;. Caddy connects locally, and the outside world only sees HTTPS. I ran my tests over plain HTTP on localhost with no public domain, so this step follows Caddy's docs rather than my lab. The &lt;a href="https://deploylark.com/posts/vaultwarden-https-required-fix/" rel="noopener noreferrer"&gt;Vaultwarden HTTPS guide&lt;/a&gt; shows a tested Caddy setup with a local certificate, and the &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt; covers a web-UI alternative.&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%2Fnbjrcw476noax9xtzj3j.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%2Fnbjrcw476noax9xtzj3j.webp" alt="Diagram of the finished setup. A visitor's browser loads script.js and sends POST /api/send to Caddy at analytics.example.com on port 443. Caddy forwards to Umami on 127.0.0.1:3000, and Umami stores data in PostgreSQL (db:5432, on a volume). Because port 3000 is bound to 127.0.0.1, only Caddy can reach Umami." width="799" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix common Umami errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tracking errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Page views don't appear, but &lt;code&gt;/api/send&lt;/code&gt; returns 200
&lt;/h4&gt;

&lt;p&gt;This one cost me some time. My first test visit came from a headless browser. &lt;code&gt;/api/send&lt;/code&gt; answered 200 OK with this body:&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;"beep"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"boop"&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;Nothing was saved. Umami runs every request through the &lt;code&gt;isbot&lt;/code&gt; library and silently drops anything that looks like a bot. A headless Chrome user agent counts as one. Here's the check in &lt;a href="https://github.com/umami-software/umami/blob/master/src/app/api/send/route.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/app/api/send/route.ts&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&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;DISABLE_BOT_CHECK&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isbot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;beep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boop&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same page loaded with a normal Chrome user agent was recorded straight away. If you test with curl, Playwright or an uptime checker, you'll see nothing. That's working as designed. &lt;code&gt;DISABLE_BOT_CHECK&lt;/code&gt; exists if you really need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;code&gt;password authentication failed for user "umami"&lt;/code&gt; (28P01) after changing the password
&lt;/h4&gt;

&lt;p&gt;I reproduced this on purpose. I changed &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; and ran &lt;code&gt;docker compose up -d&lt;/code&gt; again. Umami went into a restart loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ DATABASE_URL is defined.
✓ Database connection successful.
✗
Invalid `prisma.$queryRaw()` invocation:
Raw query failed. Code: `28P01`. Message: `password authentication failed for user "umami"`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, it prints "Database connection successful" right before failing. Don't let that fool you.&lt;/p&gt;

&lt;p&gt;The reason is that the Postgres image only reads &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; when it creates an empty data volume. After that, the password is stored in the database, and changing the env var does nothing. To fix it, set the database password to match the new value:&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; umami &lt;span class="nt"&gt;-d&lt;/span&gt; umami &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ALTER USER umami WITH PASSWORD '&amp;lt;new password from .env&amp;gt;';"&lt;/span&gt;
docker compose restart umami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the password in single quotes inside the SQL. That's how PostgreSQL marks a string literal. If you generated it with &lt;code&gt;openssl rand -hex&lt;/code&gt; as shown earlier, it's only &lt;code&gt;0-9a-f&lt;/code&gt;, so there's nothing to escape.&lt;/p&gt;

&lt;p&gt;After that, the app container was healthy again and &lt;code&gt;/api/heartbeat&lt;/code&gt; returned &lt;code&gt;{"ok":true}&lt;/code&gt;. If you get Prisma errors like P1000 even though the credentials look right, also read &lt;a href="https://github.com/umami-software/umami/issues/4459" rel="noopener noreferrer"&gt;#4459&lt;/a&gt;. There, a collaborator traced one case back to 2FA being enabled without &lt;code&gt;TWO_FACTOR_ENCRYPTION_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;code&gt;ERROR: "check-db" exited with 1&lt;/code&gt; after an upgrade
&lt;/h4&gt;

&lt;p&gt;On upgrades, Umami runs database migrations at startup. In &lt;a href="https://github.com/umami-software/umami/issues/3408" rel="noopener noreferrer"&gt;#3408&lt;/a&gt; (2.17 to 2.18), a migration on a large table took more than 15 minutes. Meanwhile the compose healthcheck (5 s × 5 retries) marked the container unhealthy and restarted it partway through. If you have a lot of data, back up first, watch the logs, and don't restart while migrations are running. My test database was empty, so I didn't see this myself.&lt;/p&gt;

&lt;h4&gt;
  
  
  Prisma &lt;code&gt;P3009&lt;/code&gt; (failed migration)
&lt;/h4&gt;

&lt;p&gt;If a migration was interrupted, Umami refuses to start with P3009 (&lt;a href="https://github.com/umami-software/umami/issues/2645" rel="noopener noreferrer"&gt;#2645&lt;/a&gt;, 58 comments). The collaborator's advice is to look in the &lt;code&gt;_prisma_migrations&lt;/code&gt; table to see which migration failed. If its changes are actually in place, mark it as applied, for example with &lt;code&gt;npx prisma migrate resolve --applied "&amp;lt;migration name&amp;gt;"&lt;/code&gt;. Use the name of your own failed migration from &lt;code&gt;_prisma_migrations&lt;/code&gt;. &lt;code&gt;05_add_visit_id&lt;/code&gt; was the one in that v2-era thread and is only an example. I haven't checked whether &lt;code&gt;npx prisma&lt;/code&gt; is available in the v3 runtime image, so you may need to run it from a checkout of the Umami source instead.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;code&gt;relation "account" does not exist&lt;/code&gt; in the Postgres log
&lt;/h4&gt;

&lt;p&gt;On a fresh database you may see this in the Postgres log. A collaborator explained in &lt;a href="https://github.com/umami-software/umami/issues/2145" rel="noopener noreferrer"&gt;#2145&lt;/a&gt; that it comes from the startup check probing for an old table, and it's harmless if Umami starts normally.&lt;/p&gt;

&lt;h4&gt;
  
  
  Managed Postgres that requires SSL
&lt;/h4&gt;

&lt;p&gt;Hosted databases often reject connections without encryption ("no pg_hba.conf entry ... no encryption", &lt;a href="https://github.com/umami-software/umami/issues/3687" rel="noopener noreferrer"&gt;#3687&lt;/a&gt;). The usual fix is to add &lt;code&gt;?sslmode=require&lt;/code&gt; to &lt;code&gt;DATABASE_URL&lt;/code&gt;. I used a local container, so I didn't run this case.&lt;/p&gt;

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

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

&lt;h4&gt;
  
  
  What is the default Umami username and password?
&lt;/h4&gt;

&lt;p&gt;It's admin / umami, per the &lt;a href="https://umami.is/docs/install" rel="noopener noreferrer"&gt;install docs&lt;/a&gt;, and that's what my fresh install accepted. Change it right after the first login. Users have asked for a way to set it with environment variables instead (&lt;a href="https://github.com/umami-software/umami/issues/4083" rel="noopener noreferrer"&gt;#4083&lt;/a&gt;).&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I use MySQL with Umami v3?
&lt;/h4&gt;

&lt;p&gt;No. The maintainer confirmed in &lt;a href="https://github.com/umami-software/umami/issues/3859" rel="noopener noreferrer"&gt;#3859&lt;/a&gt; that v3 doesn't run on MySQL. Use PostgreSQL, as in this guide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracking
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why isn't Umami tracking page views after I added the script?
&lt;/h4&gt;

&lt;p&gt;Questions like &lt;a href="https://github.com/umami-software/umami/discussions/4213" rel="noopener noreferrer"&gt;this discussion&lt;/a&gt; come up often. First check the bot filter described above. Visits from headless browsers and test tools are dropped while &lt;code&gt;/api/send&lt;/code&gt; still returns 200. Then check that the &lt;code&gt;data-website-id&lt;/code&gt; matches the website in your dashboard, and that the script loads over HTTPS on an HTTPS site.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I stop ad blockers from blocking Umami?
&lt;/h4&gt;

&lt;p&gt;The &lt;a href="https://docs.umami.is/docs/bypass-ad-blockers" rel="noopener noreferrer"&gt;Umami docs&lt;/a&gt; give two environment variables for self-hosted instances. &lt;code&gt;TRACKER_SCRIPT_NAME&lt;/code&gt; renames &lt;code&gt;script.js&lt;/code&gt; (for example to &lt;code&gt;x.js&lt;/code&gt;), and &lt;code&gt;COLLECT_API_ENDPOINT&lt;/code&gt; renames &lt;code&gt;/api/send&lt;/code&gt; (for example to &lt;code&gt;/api/x&lt;/code&gt;). This is a common complaint, as in &lt;a href="https://github.com/umami-software/umami/issues/726" rel="noopener noreferrer"&gt;#726&lt;/a&gt; about Brave. I didn't test ad blockers.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I exclude my own visits?
&lt;/h4&gt;

&lt;p&gt;Per the &lt;a href="https://docs.umami.is/docs/exclude-my-own-visits" rel="noopener noreferrer"&gt;docs&lt;/a&gt;, open your site, open the browser console and run &lt;code&gt;localStorage.setItem('umami.disabled', 1);&lt;/code&gt;. The setting is per website, so repeat it on each site you track. &lt;code&gt;localStorage.removeItem('umami.disabled');&lt;/code&gt; turns tracking back on. People ask about this in &lt;a href="https://github.com/umami-software/umami/discussions/2305" rel="noopener noreferrer"&gt;discussion #2305&lt;/a&gt;, and I didn't test it.&lt;/p&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;How to Self-Host Umami Analytics with Docker and PostgreSQL&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>analytics</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Paperless-ngx Docker Compose Setup with OCR</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:05:31 +0000</pubDate>
      <link>https://dev.to/deploylark/paperless-ngx-docker-compose-setup-with-ocr-24mp</link>
      <guid>https://dev.to/deploylark/paperless-ngx-docker-compose-setup-with-ocr-24mp</guid>
      <description>&lt;p&gt;Paperless-ngx turns a pile of paper into a searchable archive. You scan a letter or drop a PDF into a folder, and Paperless runs OCR on it, stores the original plus a searchable PDF/A copy, and makes every word findable. It's one of the most popular self-hosted apps. On October 15, 2026, the &lt;a href="https://github.com/paperless-ngx/paperless-ngx" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; had about 46,700 stars, and the &lt;a href="https://hub.docker.com/r/paperlessngx/paperless-ngx" rel="noopener noreferrer"&gt;&lt;code&gt;paperlessngx/paperless-ngx&lt;/code&gt; image&lt;/a&gt; had been pulled about 38 million times from Docker Hub. That figure leaves out GitHub's registry, which the official compose files use by default.&lt;/p&gt;

&lt;p&gt;The official compose files get you running, but they leave three traps for a new install. They ship a placeholder secret key that v3 refuses to start with, they publish the web UI on every interface before you've created the admin account, and they say little about OCR languages, which decide whether "Müller" ends up in your archive as "Muller". This guide fixes all three. I ran every step below on a test box with Paperless-ngx 3.3.1, OCR'd scans I generated in English, German and Polish, imported a Word file and an email attachment, then tested a backup and an upgrade.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Start from the official &lt;code&gt;docker-compose.postgres-tika.yml&lt;/code&gt;, pin every image tag, bind the port to &lt;code&gt;127.0.0.1:8000&lt;/code&gt;, and put a random &lt;code&gt;PAPERLESS_SECRET_KEY&lt;/code&gt; and database password in a &lt;code&gt;.env&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;Open the web UI right away. The first account you sign up with becomes the superuser.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_OCR_LANGUAGE&lt;/code&gt; picks the OCR languages (&lt;code&gt;deu+eng&lt;/code&gt;). &lt;code&gt;PAPERLESS_OCR_LANGUAGES&lt;/code&gt; installs extra ones (&lt;code&gt;pol&lt;/code&gt;). English, German, French, Italian and Spanish are already in the image.&lt;/li&gt;
&lt;li&gt;Drop files into &lt;code&gt;./consume&lt;/code&gt; or add an IMAP account and a mail rule. Back up with &lt;code&gt;document_exporter --passphrase&lt;/code&gt;, and upgrade by changing the tag and running &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you need and the compose file
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The five containers
&lt;/h3&gt;

&lt;p&gt;Paperless-ngx in Docker is one app container plus helpers. The diagram shows the stack this guide builds:&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%2F8rv9rr377a0tf9xq6vtf.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%2F8rv9rr377a0tf9xq6vtf.webp" alt="Diagram of the Paperless-ngx stack in one Docker Compose project. A browser (via SSH tunnel or proxy), the ./consume folder bind-mounted from the host and an IMAP mailbox polled by mail rules all feed the webserver container, which runs Paperless-ngx 3.3.1 with OCR and is published on 127.0.0.1:8000. The webserver talks to db (PostgreSQL 18.6, the database), broker (Valkey 9.1.2, task queue), tika (Apache Tika 3.3.1.0, Office text) and gotenberg (Gotenberg 8.37.0, Office to PDF). Data lives in the named volumes data, media, pgdata and redisdata." width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;webserver&lt;/code&gt; is Paperless itself. The image bundles the web UI, the background workers, the consume folder watcher and the OCR tools. In 3.3.1 that was Tesseract 5.5.0 and OCRmyPDF 17.12.1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db&lt;/code&gt; is PostgreSQL. The &lt;a href="https://docs.paperless-ngx.com/setup/" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt; recommend it for new installs. SQLite and MariaDB also work.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;broker&lt;/code&gt; is Valkey, a Redis-compatible store that queues background tasks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tika&lt;/code&gt; and &lt;code&gt;gotenberg&lt;/code&gt; are optional. They let Paperless read Office files such as &lt;code&gt;.docx&lt;/code&gt;, &lt;code&gt;.xlsx&lt;/code&gt; and &lt;code&gt;.odt&lt;/code&gt;, plus &lt;code&gt;.eml&lt;/code&gt; email files. Without them, it handles PDFs, images and plain text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need a Linux host with Docker and the Compose plugin. The project doesn't publish minimum hardware. On my 8-core test box, after all the tests below, the five containers used about 1.3 GB of RAM together, and most of that was Paperless itself (825 MB) and Tika (261 MB). The setup docs have a section for low-powered devices like a Raspberry Pi, with settings such as &lt;code&gt;PAPERLESS_OCR_PAGES=1&lt;/code&gt; that cut the OCR work.&lt;/p&gt;

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

&lt;p&gt;The official files live in the repo's &lt;a href="https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose" rel="noopener noreferrer"&gt;docker/compose folder&lt;/a&gt;. This is &lt;code&gt;docker-compose.postgres-tika.yml&lt;/code&gt; from the v3.3.1 tag with four changes. Every image is pinned to the exact version I tested (upstream uses &lt;code&gt;latest&lt;/code&gt; for Paperless and tags like &lt;code&gt;postgres:18&lt;/code&gt; and &lt;code&gt;valkey:9-alpine&lt;/code&gt; that move with each release), the port listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only, and the database password and secret key come from &lt;code&gt;.env&lt;/code&gt; instead of being hard-coded.&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; ~/paperless/consume ~/paperless/export &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/paperless
&lt;span class="nb"&gt;umask &lt;/span&gt;077
&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;
COMPOSE_PROJECT_NAME=paperless
POSTGRES_PASSWORD=&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="sh"&gt;
PAPERLESS_SECRET_KEY=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import secrets; print(secrets.token_urlsafe(64))'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;umask &lt;/span&gt;022
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;umask 077&lt;/code&gt; makes the new &lt;code&gt;.env&lt;/code&gt; readable only by you. Compose reads &lt;code&gt;.env&lt;/code&gt; automatically and fills in the &lt;code&gt;${...}&lt;/code&gt; values below. Keep &lt;code&gt;COMPOSE_PROJECT_NAME=paperless&lt;/code&gt;, because upstream's &lt;code&gt;.env&lt;/code&gt; sets it too, and it decides the volume names (&lt;code&gt;paperless_media&lt;/code&gt; and so on).&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;broker&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.io/valkey/valkey:9.1.2-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;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;redisdata:/data&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;docker.io/library/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;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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;paperless&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;paperless&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
  &lt;span class="na"&gt;webserver&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/paperless-ngx/paperless-ngx:3.3.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;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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;broker&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gotenberg&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tika&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:8000:8000"&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;data:/usr/src/paperless/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;media:/usr/src/paperless/media&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./export:/usr/src/paperless/export&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./consume:/usr/src/paperless/consume&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose.env&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;PAPERLESS_REDIS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://broker:6379&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_DBHOST&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;PAPERLESS_DBENGINE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_DBPASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PAPERLESS_SECRET_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_TIKA_ENABLED&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;PAPERLESS_TIKA_GOTENBERG_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://gotenberg:3000&lt;/span&gt;
      &lt;span class="na"&gt;PAPERLESS_TIKA_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://tika:9998&lt;/span&gt;
  &lt;span class="na"&gt;gotenberg&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.io/gotenberg/gotenberg:8.37.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;gotenberg"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--chromium-disable-javascript=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--chromium-allow-list=^file:///tmp/.*"&lt;/span&gt;
  &lt;span class="na"&gt;tika&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.io/apache/tika:3.3.1.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="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;media&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;span class="na"&gt;redisdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save it as &lt;code&gt;docker-compose.yml&lt;/code&gt;. The Gotenberg flags are upstream's. Gotenberg's Chromium converts &lt;code&gt;.eml&lt;/code&gt; files, and the compose file's own comment says the flags stop it from loading external content such as tracking pixels or JavaScript.&lt;/p&gt;

&lt;p&gt;The PostgreSQL volume is mounted at &lt;code&gt;/var/lib/postgresql&lt;/code&gt;, not &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;. That's correct for PostgreSQL 18. The 18.6 image sets &lt;code&gt;PGDATA=/var/lib/postgresql/18/docker&lt;/code&gt;, so the database sits in a versioned folder inside the volume.&lt;/p&gt;

&lt;p&gt;Next, &lt;code&gt;docker-compose.env&lt;/code&gt; holds the Paperless settings. Upstream's copy contains &lt;code&gt;PAPERLESS_SECRET_KEY=change-me&lt;/code&gt;. Don't keep that line. This is the file I ended up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;USERMAP_UID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1000&lt;/span&gt;
&lt;span class="py"&gt;USERMAP_GID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1000&lt;/span&gt;
&lt;span class="py"&gt;PAPERLESS_TIME_ZONE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Europe/Berlin&lt;/span&gt;
&lt;span class="py"&gt;PAPERLESS_OCR_LANGUAGE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deu+eng&lt;/span&gt;
&lt;span class="c"&gt;#PAPERLESS_OCR_LANGUAGES=pol
#PAPERLESS_URL=https://paperless.example.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;USERMAP_UID&lt;/code&gt; and &lt;code&gt;USERMAP_GID&lt;/code&gt; to the output of &lt;code&gt;id -u&lt;/code&gt; and &lt;code&gt;id -g&lt;/code&gt;, so the container can read and delete what you put in &lt;code&gt;./consume&lt;/code&gt;. Set &lt;code&gt;PAPERLESS_TIME_ZONE&lt;/code&gt; to your own zone, since it defaults to UTC. The OCR and URL lines are explained below.&lt;/p&gt;

&lt;p&gt;Why the secret key matters: in v3 it's required. The &lt;a href="https://docs.paperless-ngx.com/configuration/#PAPERLESS_SECRET_KEY" rel="noopener noreferrer"&gt;configuration docs&lt;/a&gt; say "Paperless will refuse to start if this is not set." It refuses the placeholder too. When I started 3.3.1 without a key, it stopped with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;django.core.exceptions.ImproperlyConfigured: PAPERLESS_SECRET_KEY is not set or is the default 'change-me' value. A unique, secret key is required for secure operation. Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(64))"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  First start and the superuser
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Start the stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; webserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the images already pulled, my first start took about 35 seconds from &lt;code&gt;up -d&lt;/code&gt; to a working login page. The log runs the database migrations and ends with lines like these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[init-complete] paperless-ngx docker container init completed in 22 seconds
[INFO] Listening at: http://:::8000
[paperless.management.consumer] Watching /usr/src/paperless/consume using native file system events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 8000 now only answers on the server itself. Reach it with an SSH tunnel (&lt;code&gt;ssh -L 8000:127.0.0.1:8000 you@server&lt;/code&gt;, then open &lt;code&gt;http://localhost:8000&lt;/code&gt;), or put a reverse proxy in front. The &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt; covers a web UI proxy with a free certificate, and the &lt;a href="https://deploylark.com/posts/self-host-umami-docker-postgresql/" rel="noopener noreferrer"&gt;Umami guide&lt;/a&gt; shows the same &lt;code&gt;127.0.0.1&lt;/code&gt; binding behind Caddy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the admin account
&lt;/h3&gt;

&lt;p&gt;There's no default login. On a fresh install, the first visit redirects to a sign-up 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%2Ftzlvkb9ke39udlo9yntg.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%2Ftzlvkb9ke39udlo9yntg.webp" alt="Paperless-ngx 3.3.1 sign-up page on a fresh install, with the note: This is the first user account for this installation and will be granted superuser privileges. Fields for username, optional email, password and password again, and a Sign up button." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That note is the reason for the &lt;code&gt;127.0.0.1&lt;/code&gt; binding. Whoever reaches this page first owns every document. Upstream's &lt;code&gt;"8000:8000"&lt;/code&gt; publishes the port on all interfaces, and &lt;a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" rel="noopener noreferrer"&gt;Docker's firewall docs&lt;/a&gt; explain that published ports skip ufw's rules. The &lt;a href="https://deploylark.com/posts/open-webui-cannot-connect-ollama-docker/" rel="noopener noreferrer"&gt;Open WebUI guide&lt;/a&gt; runs into the same first-account problem. Create your account as soon as the stack is up.&lt;/p&gt;

&lt;p&gt;You can also have the container create it. Per the configuration docs, &lt;code&gt;PAPERLESS_ADMIN_USER&lt;/code&gt; and &lt;code&gt;PAPERLESS_ADMIN_PASSWORD&lt;/code&gt; create a superuser at start, and they won't change an existing user's password. On a test stack, with the values passed through &lt;code&gt;environment:&lt;/code&gt; like the secret key, the log printed &lt;code&gt;[init-superuser] Creating superuser...&lt;/code&gt; and logging in with them worked.&lt;/p&gt;

&lt;p&gt;The setup docs also suggest making a separate everyday user, since a superuser can see and change everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCR languages that actually work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  PAPERLESS_OCR_LANGUAGE vs PAPERLESS_OCR_LANGUAGES
&lt;/h3&gt;

&lt;p&gt;The two names differ by one letter and do different jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_OCR_LANGUAGE&lt;/code&gt; (singular) tells Tesseract which language or languages to read. It takes three-letter codes joined with &lt;code&gt;+&lt;/code&gt;, such as &lt;code&gt;deu+eng&lt;/code&gt;. It defaults to &lt;code&gt;eng&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_OCR_LANGUAGES&lt;/code&gt; (plural) is Docker only. It installs extra language packs when the container starts, as a space-separated list such as &lt;code&gt;pol tur&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The image already contains English, German, French, Italian and Spanish. &lt;code&gt;tesseract --list-langs&lt;/code&gt; inside my 3.3.1 container showed &lt;code&gt;deu&lt;/code&gt;, &lt;code&gt;eng&lt;/code&gt;, &lt;code&gt;fra&lt;/code&gt;, &lt;code&gt;ita&lt;/code&gt;, &lt;code&gt;spa&lt;/code&gt; and &lt;code&gt;osd&lt;/code&gt; (orientation detection), plus &lt;code&gt;pol&lt;/code&gt; once I added it.&lt;/p&gt;

&lt;p&gt;To see why the singular setting matters, I generated a scanned German letter with no text layer and fed it in twice. Here's what Paperless stored:&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%2F113muq9dlkxm3bf08ckz.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%2F113muq9dlkxm3bf08ckz.webp" alt="Terminal capture comparing the OCR text Paperless-ngx 3.3.1 stored for the same scanned German letter. With PAPERLESS_OCR_LANGUAGE=eng the lines read Frau Muller, fur 2025, andert sich and Mit freundlichen Griffen. With PAPERLESS_OCR_LANGUAGE=deu+eng they read Frau Müller, für 2025, ändert sich and Mit freundlichen Grüßen." width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;eng&lt;/code&gt;, every umlaut was dropped and "Grüßen" became "Griffen". The search index forgave some of that. A search for "Müller" found both copies. A search for "Grüßen" found only the one read with &lt;code&gt;deu+eng&lt;/code&gt;. Set the language before you import your archive, because documents you've already added keep their old text unless you reprocess them.&lt;/p&gt;

&lt;p&gt;For a language that isn't in the image, you need both settings. First I set only &lt;code&gt;PAPERLESS_OCR_LANGUAGE=pol+eng&lt;/code&gt;. The container refused to finish starting and restarted over and over with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?: The selected ocr language pol is not installed. Paperless cannot OCR your documents without it. Please fix PAPERLESS_OCR_LANGUAGE.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding the install line fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;PAPERLESS_OCR_LANGUAGE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pol+eng&lt;/span&gt;
&lt;span class="py"&gt;PAPERLESS_OCR_LANGUAGES&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pol&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log showed &lt;code&gt;[init-tesseract-langs] Installing package tesseract-ocr-pol...&lt;/code&gt; and then &lt;code&gt;Installed tesseract-ocr-pol&lt;/code&gt;. A scanned Polish letter then came back with "Urząd Skarbowy w Łodzi", "Żeromskiego" and "1 234,56 zł" intact.&lt;/p&gt;

&lt;p&gt;Two things to know about that install. It downloads the Debian package when the container starts, so the container needs internet access then. And it happens again every time the container is recreated. After a plain restart the log said &lt;code&gt;Package tesseract-ocr-pol already installed!&lt;/code&gt;, but after &lt;code&gt;docker compose up -d&lt;/code&gt; with changed settings it downloaded the package again. The docs add that this option doesn't work in rootless containers. Package names use hyphens where Tesseract codes use underscores, so traditional Chinese is &lt;code&gt;chi-tra&lt;/code&gt; in &lt;code&gt;PAPERLESS_OCR_LANGUAGES&lt;/code&gt; and &lt;code&gt;chi_tra&lt;/code&gt; in &lt;code&gt;PAPERLESS_OCR_LANGUAGE&lt;/code&gt;. When a user asked how to install a language pack by hand, maintainer stumpylog &lt;a href="https://github.com/paperless-ngx/paperless-ngx/discussions/10628" rel="noopener noreferrer"&gt;replied&lt;/a&gt;: "You don't do it manually with Docker." Use the environment variable.&lt;/p&gt;

&lt;p&gt;Every language you add costs time. The docs warn that "Tesseract uses much more CPU time with multiple languages enabled," so list only the ones your documents use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other OCR settings worth knowing
&lt;/h3&gt;

&lt;p&gt;The defaults are sensible. These are the ones you're most likely to change, as described in the &lt;a href="https://docs.paperless-ngx.com/configuration/#ocr" rel="noopener noreferrer"&gt;configuration docs&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_OCR_MODE&lt;/code&gt; defaults to &lt;code&gt;auto&lt;/code&gt;. Paperless checks whether a PDF already has text and skips OCR if it does. &lt;code&gt;redo&lt;/code&gt; replaces existing text layers, which helps when your scanner's own OCR is poor. &lt;code&gt;force&lt;/code&gt; rasterizes everything first. &lt;code&gt;off&lt;/code&gt; never runs OCR.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_ARCHIVE_FILE_GENERATION&lt;/code&gt; decides when a PDF/A copy is made. The default &lt;code&gt;auto&lt;/code&gt; makes one for scans and image PDFs and skips born-digital PDFs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_OCR_PAGES=1&lt;/code&gt; OCRs only the first page, which the docs suggest for slow hardware.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PAPERLESS_OCR_CLEAN&lt;/code&gt; runs unpaper before OCR. The default is &lt;code&gt;clean&lt;/code&gt;. The setup docs say &lt;code&gt;none&lt;/code&gt; speeds up OCR and uses less memory, at the cost of slightly worse results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Version 3 removed some older OCR settings, including &lt;code&gt;PAPERLESS_OCR_MODE=skip&lt;/code&gt; and &lt;code&gt;PAPERLESS_OCR_SKIP_ARCHIVE_FILE&lt;/code&gt;. The &lt;a href="https://docs.paperless-ngx.com/migration-v3/" rel="noopener noreferrer"&gt;v3 migration guide&lt;/a&gt; maps each one to its replacement, and old values aren't honored, so check any settings you copy from an older guide.&lt;/p&gt;

&lt;p&gt;OCR is good, not perfect. On my test invoice, the invoice number, both dates and the total came out exactly, and a search for &lt;code&gt;RP-2026-0457&lt;/code&gt; found the document. Two line items with dotted leaders (&lt;code&gt;Labour, 2 hours ......&lt;/code&gt;) came out garbled. Expect to fix titles and dates by hand now and then, and keep the original file, which Paperless always does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting documents in: consume folder and email
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The consume folder
&lt;/h3&gt;

&lt;p&gt;Anything you put in &lt;code&gt;./consume&lt;/code&gt; gets imported and then removed from the folder. When I copied the English scan in, the watcher waited until the file had stopped changing (the default stability delay is 5 seconds), and the import itself took about 7 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[paperless.management.consumer] File is stable: /usr/src/paperless/consume/invoice-scan-en.pdf
[ocrmypdf._pipelines._common] Output file is a PDF/A-2b (as expected)
[paperless.consumer] Document 2026-10-03 invoice-scan-en consumption finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paperless also read the date from the text. The document got 2026-10-03, the invoice date printed on it. Open any document and the Content tab shows the text OCR produced, next to a preview:&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%2Fsebz6lsh3hd0x9c0xgk0.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%2Fsebz6lsh3hd0x9c0xgk0.webp" alt="Paperless-ngx document detail page for the German test letter, with the Content tab open on the left showing the OCR text including Frau Müller and Mit freundlichen Grüßen, and the scanned page preview on the right." width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With Tika and Gotenberg running, Office files work the same way. A &lt;code&gt;.docx&lt;/code&gt; I dropped in went to Tika for its text, then to Gotenberg for a PDF copy. One catch: Paperless took the document date from the file's metadata. My generated test file carried a 2013 creation date from its template, so the document was filed under December 24, 2013. Check dates on Office files.&lt;/p&gt;

&lt;p&gt;Scanners and sync tools often write files as a different user. To see what that looks like, I put a file in &lt;code&gt;./consume&lt;/code&gt; owned by root with mode 600. Paperless runs as UID 1000, so it logged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERROR] [paperless.management.consumer] Error while queuing document /usr/src/paperless/consume/root-owned.pdf
PermissionError: [Errno 13] Permission denied: '/usr/src/paperless/consume/root-owned.pdf'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;sudo chown 1000:1000&lt;/code&gt; on the file, Paperless picked it up again by itself and imported it. If your scanner writes to the folder over the network, make sure the files arrive owned by the UID in &lt;code&gt;USERMAP_UID&lt;/code&gt;, or readable by it.&lt;/p&gt;

&lt;p&gt;If the folder is on NFS or an SMB share, Paperless may never notice new files, because those file systems often don't send change notifications. The configuration docs say to set &lt;code&gt;PAPERLESS_CONSUMER_POLLING_INTERVAL&lt;/code&gt; to a number of seconds in that case. That's the v3 name. Older guides call it &lt;code&gt;PAPERLESS_CONSUMER_POLLING&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Email import basics
&lt;/h3&gt;

&lt;p&gt;Paperless can also pull attachments from a mailbox. In the UI, go to Mail, add an account, then add a rule for it. I tested this against a throwaway IMAP server on the same Docker network:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Mail account: IMAP server, port, security (None, SSL or STARTTLS), username and password.&lt;/li&gt;
&lt;li&gt;Mail rule: folder &lt;code&gt;INBOX&lt;/code&gt;, maximum age 30 days, "Only process attachments", and the action "Mark as read".&lt;/li&gt;
&lt;/ol&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%2Fj7gpuzc74vdsyfi1gm4m.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%2Fj7gpuzc74vdsyfi1gm4m.webp" alt="Paperless-ngx Mail Settings page with one mail account named Lab inbox on server plgreenmail with username scans@lab.test and a Process Mail button, and one enabled mail rule named Scans to Paperless for that account." width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I sent a mail with a PDF attachment to the test mailbox and triggered a fetch through the API, which is what the Process Mail button does. The log showed &lt;code&gt;Rule Lab inbox.Scans to Paperless: Consuming attachment stadtwerke-brief.pdf from mail Scan from office printer from scanner@lab.test&lt;/code&gt;, and the new document took its title from the mail subject. Without a manual trigger, Paperless checks mail on a schedule. &lt;code&gt;PAPERLESS_EMAIL_TASK_CRON&lt;/code&gt; defaults to every ten minutes.&lt;/p&gt;

&lt;p&gt;The rule action matters. Per the &lt;a href="https://docs.paperless-ngx.com/usage/#incoming-mail" rel="noopener noreferrer"&gt;usage docs&lt;/a&gt;, "Mark as read" means Paperless ignores mail that's already read, so a mail you open on your phone first won't be imported. "Move to folder" or a custom tag avoids that. "Delete" removes consumed mail right away, and the docs say to use it with caution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups, upgrades and common errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Back up and upgrade
&lt;/h3&gt;

&lt;p&gt;The document exporter writes all documents, thumbnails and database contents to a folder. With the compose file above, that's &lt;code&gt;./export&lt;/code&gt; on the host:&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; &lt;span class="nt"&gt;-T&lt;/span&gt; webserver document_exporter ../export &lt;span class="nt"&gt;--passphrase&lt;/span&gt; &lt;span class="s1"&gt;'a-long-passphrase'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't skip &lt;code&gt;--passphrase&lt;/code&gt;. Without it, the exporter printed &lt;code&gt;No passphrase was given, sensitive fields will be in plaintext&lt;/code&gt;, and my test mail account's password was readable in &lt;code&gt;export/manifest.json&lt;/code&gt;. With a passphrase, that field was encrypted. Keep the passphrase somewhere other than the backup.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.paperless-ngx.com/administration/#backup" rel="noopener noreferrer"&gt;administration docs&lt;/a&gt; add a warning: you can't import an export into a different Paperless version. The other option is to back up the Docker volumes themselves (&lt;code&gt;paperless_media&lt;/code&gt;, &lt;code&gt;paperless_data&lt;/code&gt; and &lt;code&gt;paperless_pgdata&lt;/code&gt;), which you need root to read.&lt;/p&gt;

&lt;p&gt;To upgrade, back up first, then change the tag. I tested this from 3.3.0 to 3.3.1 on a separate stack:&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; &lt;span class="nt"&gt;-T&lt;/span&gt; webserver document_exporter ../export &lt;span class="nt"&gt;--passphrase&lt;/span&gt; &lt;span class="s1"&gt;'a-long-passphrase'&lt;/span&gt;
docker compose down
&lt;span class="c"&gt;# edit docker-compose.yml: paperless-ngx:3.3.0 -&amp;gt; paperless-ngx:3.3.1&lt;/span&gt;
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;&lt;code&gt;docker compose down&lt;/code&gt; without &lt;code&gt;-v&lt;/code&gt; keeps the volumes. The new container ran the migration step (&lt;code&gt;No migrations to apply.&lt;/code&gt; for this patch release), &lt;code&gt;/api/status/&lt;/code&gt; reported version 3.3.1, and the test document was still there and searchable. Read the &lt;a href="https://github.com/paperless-ngx/paperless-ngx/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; before every bump. Coming from v2, the &lt;a href="https://docs.paperless-ngx.com/migration-v3/" rel="noopener noreferrer"&gt;v3 migration guide&lt;/a&gt; says you must be on 2.20.15 first.&lt;/p&gt;

&lt;p&gt;Two v3 changes catch people coming from older guides. &lt;code&gt;PAPERLESS_DBENGINE: postgresql&lt;/code&gt; is now required for PostgreSQL, and duplicates are no longer rejected (see the FAQ). Once it runs, an &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; HTTP monitor on the login page tells you when it stops answering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  The container restarts in a loop after you change OCR settings
&lt;/h4&gt;

&lt;p&gt;Check &lt;code&gt;docker compose logs webserver&lt;/code&gt; for &lt;code&gt;The selected ocr language ... is not installed&lt;/code&gt;. Add the language to &lt;code&gt;PAPERLESS_OCR_LANGUAGES&lt;/code&gt;, or remove it from &lt;code&gt;PAPERLESS_OCR_LANGUAGE&lt;/code&gt;. Remember the underscore rule for codes like &lt;code&gt;chi_tra&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  CSRF verification failed when you log in through a proxy
&lt;/h4&gt;

&lt;p&gt;The login page loads, but logging in returns &lt;code&gt;403 CSRF verification failed. Request aborted.&lt;/code&gt; To reproduce it without a real proxy, I sent the login request with curl, using the headers a TLS-terminating proxy adds for &lt;code&gt;https://paperless.example.test&lt;/code&gt;. The log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[WARNING] [django.security.csrf] Forbidden (Origin checking failed - https://paperless.example.test does not match any trusted origins.): /accounts/login/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is &lt;code&gt;PAPERLESS_URL&lt;/code&gt;, set to the public address with no trailing slash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;PAPERLESS_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://paperless.example.test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;docker compose up -d&lt;/code&gt;, the same request logged in. Per the configuration docs, &lt;code&gt;PAPERLESS_URL&lt;/code&gt; fills in Django's allowed hosts, CORS and CSRF trusted origins in one go. If it's set and you still get &lt;code&gt;Origin checking failed - null&lt;/code&gt;, a &lt;a href="https://github.com/paperless-ngx/paperless-ngx/discussions/13160" rel="noopener noreferrer"&gt;support thread&lt;/a&gt; traced that to a &lt;code&gt;Referrer-Policy: no-referrer&lt;/code&gt; header added by the proxy.&lt;/p&gt;

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

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

&lt;h4&gt;
  
  
  How much RAM does Paperless-ngx need?
&lt;/h4&gt;

&lt;p&gt;The project doesn't publish a minimum. On my test box, the full stack with PostgreSQL, Valkey, Tika and Gotenberg used about 1.3 GB of RAM after several OCR jobs. Tika and Gotenberg are optional, and leaving them out drops support for Office files. For small machines, the setup docs suggest settings like &lt;code&gt;PAPERLESS_OCR_PAGES=1&lt;/code&gt; and &lt;code&gt;PAPERLESS_OCR_CLEAN=none&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where are my documents stored with Docker?
&lt;/h4&gt;

&lt;p&gt;In the &lt;code&gt;paperless_media&lt;/code&gt; volume, which on Linux is usually under &lt;code&gt;/var/lib/docker/volumes/paperless_media/_data&lt;/code&gt;. The &lt;a href="https://docs.paperless-ngx.com/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt; says not to mess with that folder by hand. Files you put in &lt;code&gt;./consume&lt;/code&gt; are copied into the media volume and removed from the consume folder. To get your files out with readable names, use the exporter.&lt;/p&gt;

&lt;h4&gt;
  
  
  Does Paperless-ngx reject duplicate documents?
&lt;/h4&gt;

&lt;p&gt;Not since v3. When my mail test brought in a PDF I had already imported, the log said &lt;code&gt;Consuming duplicate stadtwerke-brief.pdf: 2 existing document(s) share the same content.&lt;/code&gt; and imported it anyway. Per the &lt;a href="https://docs.paperless-ngx.com/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;, the document page then shows a Duplicates tab that lists the copies. Set &lt;code&gt;PAPERLESS_CONSUMER_DELETE_DUPLICATES=true&lt;/code&gt; if you want the old behavior of rejecting them.&lt;/p&gt;

&lt;h3&gt;
  
  
  OCR and import
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why does Paperless say the selected OCR language is not installed?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;PAPERLESS_OCR_LANGUAGE&lt;/code&gt; names a language the image doesn't contain. Add it to &lt;code&gt;PAPERLESS_OCR_LANGUAGES&lt;/code&gt; so the container installs it at startup, and give the container internet access while it starts. For codes with an underscore, write &lt;code&gt;chi_tra&lt;/code&gt; in the singular setting and &lt;code&gt;chi-tra&lt;/code&gt; in the plural one.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why isn't Paperless picking up files from my consume folder?
&lt;/h4&gt;

&lt;p&gt;Check &lt;code&gt;docker compose logs webserver&lt;/code&gt; first. A &lt;code&gt;PermissionError&lt;/code&gt; means the files belong to a different user than &lt;code&gt;USERMAP_UID&lt;/code&gt;, so fix the ownership. If nothing is logged at all and the folder is on NFS or SMB, set &lt;code&gt;PAPERLESS_CONSUMER_POLLING_INTERVAL&lt;/code&gt; as the docs describe. Also check that you're writing to the folder that's mounted to &lt;code&gt;/usr/src/paperless/consume&lt;/code&gt; in the compose file.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I fix "CSRF verification failed" behind a reverse proxy?
&lt;/h4&gt;

&lt;p&gt;Set &lt;code&gt;PAPERLESS_URL&lt;/code&gt; to the exact public URL, like &lt;code&gt;https://paperless.example.com&lt;/code&gt; with no trailing slash, and recreate the container with &lt;code&gt;docker compose up -d&lt;/code&gt;. In my test, that turned a 403 into a successful login.&lt;/p&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/paperless-ngx-docker-compose-ocr/" rel="noopener noreferrer"&gt;Paperless-ngx Docker Compose Setup with OCR&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>ocr</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Nginx Proxy Manager with Docker: Proxy Hosts and Free SSL</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Sat, 17 Oct 2026 11:20:05 +0000</pubDate>
      <link>https://dev.to/deploylark/nginx-proxy-manager-with-docker-proxy-hosts-and-free-ssl-18h3</link>
      <guid>https://dev.to/deploylark/nginx-proxy-manager-with-docker-proxy-hosts-and-free-ssl-18h3</guid>
      <description>&lt;p&gt;Once you run more than one self-hosted app, you hit the same problem every time. Each app wants its own port, and none of them has HTTPS. Nginx Proxy Manager (NPM) fixes both. It sits in front of your apps, answers on ports 80 and 443, sends each domain to the right container, and gets free Let's Encrypt certificates for you, all from a web UI.&lt;/p&gt;

&lt;p&gt;It's a popular choice. On October 14, 2026, the &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager" rel="noopener noreferrer"&gt;project's GitHub repository&lt;/a&gt; had about 34,400 stars, and the &lt;a href="https://hub.docker.com/r/jc21/nginx-proxy-manager" rel="noopener noreferrer"&gt;&lt;code&gt;jc21/nginx-proxy-manager&lt;/code&gt; image&lt;/a&gt; had been pulled more than 285 million times from Docker Hub.&lt;/p&gt;

&lt;p&gt;This guide covers a clean install, the first-run setup (it changed in recent versions, so a lot of older guides are wrong), your first proxy host, both ways to get a certificate, and the errors you're most likely to hit.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;jc21/nginx-proxy-manager:2.16.0&lt;/code&gt; with ports 80 and 443 public, bind the admin UI to &lt;code&gt;127.0.0.1:81&lt;/code&gt;, and keep both the &lt;code&gt;/data&lt;/code&gt; and &lt;code&gt;/etc/letsencrypt&lt;/code&gt; volumes.&lt;/li&gt;
&lt;li&gt;There's no &lt;code&gt;admin@example.com&lt;/code&gt; / &lt;code&gt;changeme&lt;/code&gt; login on 2.16. You create the admin account on the first-run screen, so use a real email, because NPM also uses it for Let's Encrypt.&lt;/li&gt;
&lt;li&gt;Put NPM and your apps on one shared Docker network and forward to the service name and the container's internal port. &lt;code&gt;localhost&lt;/code&gt; there means NPM itself and gives you a 502.&lt;/li&gt;
&lt;li&gt;Request the certificate in the SSL tab. HTTP-01 needs DNS pointing at the server and port 80 open. DNS-01 with a scoped Cloudflare token also covers wildcards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Nginx Proxy Manager with Docker Compose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before you start
&lt;/h3&gt;

&lt;p&gt;Without a reverse proxy, you reach apps like this: &lt;code&gt;http://203.0.113.10:3001&lt;/code&gt;, &lt;code&gt;http://203.0.113.10:8080&lt;/code&gt;, and so on. That's one open port per app and no TLS.&lt;/p&gt;

&lt;p&gt;With a reverse proxy, only ports 80 and 443 are open. A request for &lt;code&gt;https://status.example.com&lt;/code&gt; reaches NPM. NPM reads the hostname and passes the request to the container that serves it (for example &lt;code&gt;uptime-kuma:3001&lt;/code&gt;) over the internal Docker network. NPM handles HTTPS, so the app behind it doesn't need to know anything about certificates.&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%2F5msbsichnw614pwyjsai.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%2F5msbsichnw614pwyjsai.webp" alt="Diagram of the setup in this guide. A browser request for https://status.example.com reaches Nginx Proxy Manager on ports 80 and 443. NPM picks the app by hostname and forwards over the proxy Docker network to uptime-kuma:3001 or n8n:5678. The admin UI listens on 127.0.0.1:81 only and is reached through an SSH tunnel." width="799" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Linux server with Docker and the Docker Compose plugin.&lt;/li&gt;
&lt;li&gt;A domain, with an &lt;code&gt;A&lt;/code&gt; record for each hostname (or a wildcard &lt;code&gt;*.example.com&lt;/code&gt;) pointing at the server's public IP.&lt;/li&gt;
&lt;li&gt;Ports 80 and 443 reachable from the internet (cloud firewall, router port forwarding). Port 80 matters even if you only want HTTPS, because Let's Encrypt's HTTP-01 check &lt;a href="https://letsencrypt.org/docs/challenge-types/" rel="noopener noreferrer"&gt;only works on port 80&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Nothing else already listening on 80, 443 or 81 on the host.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The compose file and first start
&lt;/h3&gt;

&lt;p&gt;Create a folder and a compose file. This is the &lt;a href="https://nginxproxymanager.com/guide/" rel="noopener noreferrer"&gt;official quick-setup file&lt;/a&gt; with three changes. The image is pinned to the current release, there's a timezone you'll want to change, and the admin port is bound to &lt;code&gt;127.0.0.1&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;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/npm &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/npm
nano compose.yaml
&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;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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jc21/nginx-proxy-manager:2.16.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;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;80:80'&lt;/span&gt;              &lt;span class="c1"&gt;# public HTTP&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;443:443'&lt;/span&gt;            &lt;span class="c1"&gt;# public HTTPS&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:81:81'&lt;/span&gt;    &lt;span class="c1"&gt;# admin UI, only reachable from the server itself&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;TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/London"&lt;/span&gt;   &lt;span class="c1"&gt;# change to your timezone&lt;/span&gt;
      &lt;span class="c1"&gt;# DISABLE_IPV6: 'true'  # uncomment if your host has no IPv6&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;./data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./letsencrypt:/etc/letsencrypt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first start runs database migrations and takes a little while. When you see &lt;code&gt;Backend PID ... listening on port 3000&lt;/code&gt;, it's ready. Check from the server itself, since port 81 only answers there now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:81/api/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a fresh install that 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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"setup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"major"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"minor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"revision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&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;&lt;code&gt;"setup":false&lt;/code&gt; means no admin account exists yet. That's the next step.&lt;/p&gt;

&lt;p&gt;Why bind port 81 to localhost? It's your admin panel, and upstream's file publishes &lt;code&gt;'81:81'&lt;/code&gt; on every interface. A host firewall won't save you here. Docker's &lt;a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" rel="noopener noreferrer"&gt;firewall docs&lt;/a&gt; say that when you publish a container's ports, "traffic to and from that container gets diverted before it goes through the ufw firewall settings." On my test box, with the &lt;code&gt;127.0.0.1&lt;/code&gt; binding, the API answered on 127.0.0.1 but not on the server's network address. To use the admin UI from your laptop, open an SSH tunnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 8181:127.0.0.1:81 you@your-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then browse to &lt;code&gt;http://localhost:8181&lt;/code&gt;. Once NPM works, you can also put the admin UI behind its own proxy host with an access list.&lt;/p&gt;

&lt;p&gt;A few more things I learned in testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep both volumes. When I started the image without &lt;code&gt;/etc/letsencrypt&lt;/code&gt; mounted, it refused to start and logged &lt;code&gt;ERROR: /etc/letsencrypt is not mounted! Check your docker configuration.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SQLite is the default and is fine for a single server. The &lt;a href="https://nginxproxymanager.com/setup/" rel="noopener noreferrer"&gt;full setup docs&lt;/a&gt; also have MariaDB, MySQL and Postgres variants.&lt;/li&gt;
&lt;li&gt;The project doesn't publish minimum requirements. On my test box the idle container used about 110 to 130 MB of RAM.&lt;/li&gt;
&lt;li&gt;Images are built for amd64 and arm64. The setup docs say armv7 was dropped in 2.14 and point 32-bit ARM users to the &lt;code&gt;2.13.7&lt;/code&gt; tag.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Create the admin account
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The setup screen replaced the default login
&lt;/h3&gt;

&lt;p&gt;Open the admin UI through the SSH tunnel at &lt;code&gt;http://localhost:8181&lt;/code&gt;. If you kept upstream's &lt;code&gt;'81:81'&lt;/code&gt;, use &lt;code&gt;http://&amp;lt;server-ip&amp;gt;:81&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've read an older guide, you're expecting to log in with &lt;code&gt;admin@example.com&lt;/code&gt; and &lt;code&gt;changeme&lt;/code&gt;. That was true up to the 2.12.x releases (it's in the &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.12.6/README.md" rel="noopener noreferrer"&gt;v2.12.6 README&lt;/a&gt;), but it was removed from the README in 2.13.0. On 2.16.0 there is no default account. You get a "Welcome! Get started by creating your admin account" screen with Full Name, Email address and New 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fducn28mohu31s7vf51j5.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%2Fducn28mohu31s7vf51j5.webp" alt="Nginx Proxy Manager 2.16 first-run setup screen, where you create the admin account with a full name, email address and password" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Use a real email address here. NPM uses your account email as the Let's Encrypt account email. In my test the certificate request ran &lt;code&gt;certbot ... -m admin@lab.test ...&lt;/code&gt;, and Let's Encrypt rejected it with &lt;code&gt;The ACME server believes admin@lab.test is an invalid email address&lt;/code&gt;. A made-up address here means certificate requests fail later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scripted setup and two-factor login
&lt;/h3&gt;

&lt;p&gt;If you'd rather create the admin account without the setup screen (useful for scripted installs), the &lt;a href="https://nginxproxymanager.com/advanced-config/" rel="noopener noreferrer"&gt;advanced config docs&lt;/a&gt; list two variables for it. Don't type the password into &lt;code&gt;compose.yaml&lt;/code&gt;. Put it in a &lt;code&gt;.env&lt;/code&gt; file next to it that only you can read:&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="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF2&lt;/span&gt;&lt;span class="sh"&gt;'
INITIAL_ADMIN_EMAIL=you@example.com
INITIAL_ADMIN_PASSWORD=a-long-random-password
&lt;/span&gt;&lt;span class="no"&gt;EOF2
&lt;/span&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reference the variables in &lt;code&gt;compose.yaml&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;INITIAL_ADMIN_EMAIL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${INITIAL_ADMIN_EMAIL}&lt;/span&gt;
      &lt;span class="na"&gt;INITIAL_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${INITIAL_ADMIN_PASSWORD}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the first start, delete both lines from &lt;code&gt;compose.yaml&lt;/code&gt;, delete &lt;code&gt;.env&lt;/code&gt;, and run &lt;code&gt;docker compose up -d&lt;/code&gt;. I tested this. The account was created, &lt;code&gt;/api/&lt;/code&gt; reported &lt;code&gt;"setup":true&lt;/code&gt;, and after removing the variables and recreating the container I could still log in with the same password. Change the password in the UI if it was ever stored anywhere else.&lt;/p&gt;

&lt;p&gt;NPM has supported TOTP two-factor authentication since 2.13.6. The 2.16 UI includes an "Enable Two-Factor Authentication" option for your account. Turn it on, because this panel controls all your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a proxy host
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Connect NPM to your apps with a shared network
&lt;/h3&gt;

&lt;p&gt;The easiest way to connect NPM to your apps is a shared Docker network. NPM then reaches each app by its service name, and the apps don't need to publish any ports to the host. This is the approach the &lt;a href="https://nginxproxymanager.com/advanced-config/" rel="noopener noreferrer"&gt;NPM docs recommend&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;docker network create proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add this to the bottom of NPM's &lt;code&gt;compose.yaml&lt;/code&gt; and to each app's 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;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;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run &lt;code&gt;docker compose up -d&lt;/code&gt; again in each folder. If an app's compose file already defines its own networks, attach just the web service to &lt;code&gt;proxy&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;In the NPM UI, go to Hosts &amp;gt; Proxy Hosts &amp;gt; Add Proxy Host. The Details tab has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain Names: &lt;code&gt;status.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scheme: &lt;code&gt;http&lt;/code&gt;. This is the protocol between NPM and the app, which is almost always plain HTTP.&lt;/li&gt;
&lt;li&gt;Forward Hostname / IP: the container's service name, for example &lt;code&gt;uptime-kuma&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Forward Port: the port the app listens on inside its container (for example &lt;code&gt;3001&lt;/code&gt;), not a host port.&lt;/li&gt;
&lt;li&gt;Access List: leave it as Publicly Accessible for now.&lt;/li&gt;
&lt;li&gt;Options: Block Common Exploits is a sensible default. Turn on Websockets Support for any app with a live UI (see below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the Details tab from my lab run, pointing a test hostname at a container named &lt;code&gt;whoami&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Firm95q4r91efc0b3cyo0.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%2Firm95q4r91efc0b3cyo0.webp" alt="Add Proxy Host dialog in Nginx Proxy Manager 2.16, Details tab: domain status.lab.test, scheme http, forward hostname whoami, forward port 80, access list Publicly Accessible, with Block Common Exploits and Websockets Support switched on" width="541" height="657"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Save, then test from any machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://status.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my lab, a proxy host pointing at a container named &lt;code&gt;whoami&lt;/code&gt; on the shared network returned that container's page, and a hostname with no proxy host got NPM's "Congratulations!" default site. You can change that default under Settings &amp;gt; Default Site.&lt;/p&gt;

&lt;p&gt;The saved host shows up in the Proxy Hosts 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdbowwbulw4ftstifma5.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%2Fgdbowwbulw4ftstifma5.webp" alt="Nginx Proxy Manager Proxy Hosts list with one entry: source status.lab.test, destination http://whoami:80, SSL HTTP Only, access Public, status Online" width="799" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a full example app behind NPM, see &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma with Docker&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Websockets, access lists and custom locations
&lt;/h3&gt;

&lt;p&gt;Some apps won't work at all without HTTPS. Vaultwarden's web vault is one, as the &lt;a href="https://deploylark.com/posts/vaultwarden-https-required-fix/" rel="noopener noreferrer"&gt;Vaultwarden HTTPS guide&lt;/a&gt; shows. I tested that fix with Caddy, and an NPM proxy host with a Let's Encrypt certificate should fix it the same way.&lt;/p&gt;

&lt;p&gt;Websockets Support (Details tab) matters for apps with a live UI, such as Uptime Kuma, n8n, Home Assistant or Open WebUI. They break in strange ways without it, with "connection lost" banners and dashboards that never update. The &lt;a href="https://deploylark.com/posts/n8n-docker-webhook-url-connection-lost/" rel="noopener noreferrer"&gt;n8n guide&lt;/a&gt; and the &lt;a href="https://deploylark.com/posts/open-webui-cannot-connect-ollama-docker/" rel="noopener noreferrer"&gt;Open WebUI guide&lt;/a&gt; show what that looks like. When I turned the toggle on, NPM added these lines to the generated nginx config:&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;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="nv"&gt;$http_connection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you aren't sure whether an app needs it, turn it on. It doesn't hurt plain HTTP apps.&lt;/p&gt;

&lt;p&gt;Access lists (Access Lists menu) hold HTTP basic-auth users and allow or deny IP rules. Pick one in a proxy host's Access List field. It's a cheap way to lock down admin panels that have weak or no auth of their own. NPM 2.16.0 also added per-path access lists inside the proxy host modal.&lt;/p&gt;

&lt;p&gt;Custom Locations (a tab in the proxy host) send a path such as &lt;code&gt;/api&lt;/code&gt; to a different container or port than the rest of the host. Most single-app hosts don't need them.&lt;/p&gt;

&lt;p&gt;The Advanced tab (gear icon) takes raw nginx directives for the server block. Use it sparingly. If a snippet breaks the config, 2.16.0 keeps the failed file as &lt;code&gt;.conf.err&lt;/code&gt; so you can see what went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get a free Let's Encrypt certificate
&lt;/h2&gt;

&lt;p&gt;Edit the proxy host and open the SSL tab. Under SSL Certificate, pick "Request a new Certificate (with Let's Encrypt)". The other toggles are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Force SSL redirects HTTP to HTTPS. Turn it on once the certificate works.&lt;/li&gt;
&lt;li&gt;HTTP/2 Support is safe to enable.&lt;/li&gt;
&lt;li&gt;HSTS Enabled and HSTS Sub-domains tell browsers to use only HTTPS for this host (and its subdomains) for a long time. Leave these off until you're sure HTTPS works everywhere, because they're hard to undo.&lt;/li&gt;
&lt;/ul&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%2Fkmoerakgk7cifwi6pnff.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%2Fkmoerakgk7cifwi6pnff.webp" alt="SSL tab of the Add Proxy Host dialog with the SSL Certificate dropdown open, offering None (this host will not use HTTPS) or Request a new Certificate with Let's Encrypt" width="541" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then pick a challenge type.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP-01 (the default)
&lt;/h3&gt;

&lt;p&gt;Per &lt;a href="https://letsencrypt.org/docs/challenge-types/" rel="noopener noreferrer"&gt;Let's Encrypt's docs&lt;/a&gt;, HTTP-01 asks for a token at &lt;code&gt;http://&amp;lt;your-domain&amp;gt;/.well-known/acme-challenge/...&lt;/code&gt; and checks that your server returns it. NPM serves that path itself, from a &lt;code&gt;letsencrypt-acme-challenge&lt;/code&gt; folder in its data volume. The challenge can only succeed when DNS for the hostname already points at this server and port 80 is reachable from the internet. On Oct 14, 2026, I verified this domain and HTTP-01 path by getting a real Let's Encrypt certificate over HTTP-01 on a public Debian 13 server running Caddy, while NPM's own certificate request screen follows NPM's documentation.&lt;/p&gt;

&lt;p&gt;HTTP-01 can't issue wildcard certificates, and it fails if your ISP blocks port 80.&lt;/p&gt;

&lt;p&gt;NPM 2.16's code also includes a reachability check for HTTP-01. It writes a test file and asks an outside service (site24x7.com) to fetch it from your domain. Run it before you request a real certificate, because failed attempts count toward rate limits (covered below).&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS-01 with Cloudflare
&lt;/h3&gt;

&lt;p&gt;With DNS-01, according to Let's Encrypt's docs, certbot proves you control the domain by creating a TXT record at &lt;code&gt;_acme-challenge.&amp;lt;your-domain&amp;gt;&lt;/code&gt;. Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you want a wildcard certificate (&lt;code&gt;*.example.com&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;the server isn't reachable on port 80 (a home lab behind CGNAT, or an internal-only service), or&lt;/li&gt;
&lt;li&gt;you have several servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the SSL tab, turn on Use DNS Challenge, choose your DNS Provider, and paste the credentials. NPM 2.16.0 ships certbot plugins for a long list of providers, Cloudflare included. For Cloudflare, NPM's template is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cloudflare API token
&lt;/span&gt;&lt;span class="py"&gt;dns_cloudflare_api_token&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-cloudflare-api-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create an API token (not the Global API Key) in the Cloudflare dashboard. The &lt;a href="https://certbot-dns-cloudflare.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;certbot-dns-cloudflare docs&lt;/a&gt; say it needs &lt;code&gt;Zone:DNS:Edit&lt;/code&gt; permission, only for the zones you need certificates for.&lt;/p&gt;

&lt;p&gt;Two cautions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That token can change your DNS. Let's Encrypt's challenge docs warn that keeping DNS API credentials on a web server raises the stakes if the server is compromised, so scope the token as narrowly as you can.&lt;/li&gt;
&lt;li&gt;2.16.0 updated certbot, and the &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/v2.16.0" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; warn that some DNS plugins may need dependency tweaks. If your provider's plugin errors after an upgrade, check the issue tracker before you assume your token is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix common SSL errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Certificate requests that fail
&lt;/h3&gt;

&lt;h4&gt;
  
  
  "Internal Error" when requesting a certificate
&lt;/h4&gt;

&lt;p&gt;This is NPM's generic message. The real reason is in the certbot log, which sits in your data volume:&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 tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50 ~/npm/data/logs/letsencrypt.log   &lt;span class="c"&gt;# the data folder is owned by root&lt;/span&gt;
docker compose logs app | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; &lt;span class="s2"&gt;"SSL&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;certbot"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual causes are DNS not pointing at the server yet, port 80 blocked, or an invalid account email (see above).&lt;/p&gt;

&lt;h4&gt;
  
  
  Challenge fails with "Timeout", "Connection refused" or the wrong IP
&lt;/h4&gt;

&lt;p&gt;Let's Encrypt couldn't fetch the token. Check these from outside your network (mobile data works):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short status.example.com       &lt;span class="c"&gt;# should print your server's public IP&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://status.example.com   &lt;span class="c"&gt;# should answer from NPM, not time out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the IP is wrong, fix DNS and wait for it to update. If you get a timeout, open port 80 in the cloud firewall or router. If you use Cloudflare's proxy (orange cloud) and HTTP-01 keeps failing, switch to the DNS challenge, which doesn't depend on the HTTP path.&lt;/p&gt;

&lt;h4&gt;
  
  
  Rate limited
&lt;/h4&gt;

&lt;p&gt;Retrying over and over burns Let's Encrypt's &lt;a href="https://letsencrypt.org/docs/rate-limits/" rel="noopener noreferrer"&gt;rate limits&lt;/a&gt;. The ones you're most likely to hit while troubleshooting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5 failed validations per hostname, per account, per hour.&lt;/li&gt;
&lt;li&gt;5 certificates for the exact same set of hostnames every 7 days. Reinstalling and requesting again is the classic way to hit this one.&lt;/li&gt;
&lt;li&gt;50 certificates per registered domain every 7 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Revoking certificates doesn't reset these limits. Fix the cause first, test it, then request once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Errors after the certificate is issued
&lt;/h3&gt;

&lt;h4&gt;
  
  
  ERR_TOO_MANY_REDIRECTS behind Cloudflare
&lt;/h4&gt;

&lt;p&gt;If Cloudflare's SSL/TLS mode is Flexible, Cloudflare talks to your server over plain HTTP. With Force SSL on, NPM redirects that to HTTPS, and Cloudflare requests HTTP again, in a loop. &lt;a href="https://developers.cloudflare.com/ssl/troubleshooting/too-many-redirects/" rel="noopener noreferrer"&gt;Cloudflare's fix&lt;/a&gt; is to set the mode to Full or higher, which needs a valid certificate on the origin (NPM's Let's Encrypt certificate works here), or else to stop redirecting at the origin.&lt;/p&gt;

&lt;h4&gt;
  
  
  502 Bad Gateway after a "successful" setup
&lt;/h4&gt;

&lt;p&gt;The certificate is fine, but NPM can't reach the app. The most common mistake is putting &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;127.0.0.1&lt;/code&gt; in Forward Hostname. Inside the NPM container, that address means NPM itself. In my lab, a proxy host pointed at &lt;code&gt;127.0.0.1:8080&lt;/code&gt; returned 502, and the host's error log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect() failed (111: Connection refused) while connecting to upstream ... upstream: "http://127.0.0.1:8080/"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the app's service name on the shared Docker network, with the container's internal port. If the app runs directly on the host instead of in Docker, use the host's LAN IP. Per-host logs are in &lt;code&gt;~/npm/data/logs/proxy-host-&amp;lt;id&amp;gt;_error.log&lt;/code&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Logging in and updating
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why doesn't &lt;a href="mailto:admin@example.com"&gt;admin@example.com&lt;/a&gt; / changeme work?
&lt;/h4&gt;

&lt;p&gt;Because 2.16 has no default account. Since 2.13.0, a fresh install shows a setup screen where you create the admin account yourself.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I update Nginx Proxy Manager?
&lt;/h4&gt;

&lt;p&gt;The &lt;a href="https://nginxproxymanager.com/upgrading/" rel="noopener noreferrer"&gt;upgrading page&lt;/a&gt; says to run &lt;code&gt;docker compose pull&lt;/code&gt; and &lt;code&gt;docker compose up -d&lt;/code&gt;, and adds: "This project will automatically update any databases or other requirements so you don't have to follow any crazy instructions." Some releases do add migrations, so I'd back up first. With a pinned tag, change it in &lt;code&gt;compose.yaml&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/npm
docker compose stop                       &lt;span class="c"&gt;# don't copy the SQLite file while it's in use&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; data data.bak-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
docker compose pull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; before major bumps. 2.15.0 and 2.16.0 both updated certbot and warned that DNS plugins may need changes. Version 2.13.3 added a migration and warned that you can't easily downgrade after it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why are my settings gone after Docker restarts?
&lt;/h4&gt;

&lt;p&gt;NPM keeps its database and config in &lt;code&gt;/data&lt;/code&gt; and its certificates in &lt;code&gt;/etc/letsencrypt&lt;/code&gt;. If those aren't mounted to folders or volumes on the host, recreating the container starts from scratch. Use both volume lines from the compose file above. The official image won't even start without &lt;code&gt;/etc/letsencrypt&lt;/code&gt; mounted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Certificates and errors
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do I get a wildcard certificate?
&lt;/h4&gt;

&lt;p&gt;Use the DNS challenge. HTTP-01 can't issue wildcards. Request a certificate for &lt;code&gt;*.example.com&lt;/code&gt; (and &lt;code&gt;example.com&lt;/code&gt; if you want the bare domain too), turn on Use DNS Challenge, pick your provider and paste a scoped API token, as in the Cloudflare example above.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why do I get 502 Bad Gateway?
&lt;/h4&gt;

&lt;p&gt;NPM can't reach the app at the Forward Hostname and port you gave it. Don't use &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;127.0.0.1&lt;/code&gt;, which mean the NPM container itself. Put NPM and the app on the same Docker network and use the service name and the container's internal port.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do I protect the admin panel on port 81?
&lt;/h4&gt;

&lt;p&gt;Don't publish it to the internet. Port 81 serves the login form over plain HTTP. The compose file above binds it to &lt;code&gt;127.0.0.1&lt;/code&gt;, and on my test box it then answered only on the server itself. Reach it through an SSH tunnel (&lt;code&gt;ssh -L 8181:127.0.0.1:81 you@your-server&lt;/code&gt;). A reply in &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager/issues/481" rel="noopener noreferrer"&gt;issue #481&lt;/a&gt; suggests another option: a proxy host such as &lt;code&gt;npm.example.com&lt;/code&gt; that forwards to &lt;code&gt;127.0.0.1&lt;/code&gt; port 81 with SSL, after which you remove the port 81 mapping from the compose file. If you go that way, I'd also add an access list to that proxy host so only your own IP addresses can reach it. Turn on two-factor login either way.&lt;/p&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager with Docker: Proxy Hosts and Free SSL&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>nginx</category>
      <category>ssl</category>
    </item>
    <item>
      <title>Fix n8n 'Connection Lost' and Webhook URLs Behind a Proxy</title>
      <dc:creator>DeployLark</dc:creator>
      <pubDate>Fri, 16 Oct 2026 06:48:05 +0000</pubDate>
      <link>https://dev.to/deploylark/fix-n8n-connection-lost-and-webhook-urls-behind-a-proxy-3lal</link>
      <guid>https://dev.to/deploylark/fix-n8n-connection-lost-and-webhook-urls-behind-a-proxy-3lal</guid>
      <description>&lt;p&gt;You put n8n behind a reverse proxy, open &lt;code&gt;https://n8n.example.com&lt;/code&gt;, and two things go wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A red "Connection lost" badge sits in the editor. Hovering it says: "You have a connection issue or the server is down. n8n should reconnect automatically once the issue is resolved." It never reconnects. Executions don't update live.&lt;/li&gt;
&lt;li&gt;The Webhook node shows URLs like &lt;code&gt;http://localhost:5678/webhook/...&lt;/code&gt;, or &lt;code&gt;https://n8n.example.com:5678/webhook/...&lt;/code&gt;, that nobody outside the server can call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They're separate problems with separate fixes. The first is the proxy. The second is n8n's environment variables. A third, quieter problem (n8n not trusting the proxy's headers) usually shows up in the logs at the same time. Each one was reproduced on a test box, using a test hostname over plain HTTP through the proxy, and checked every fix below.&lt;/p&gt;

&lt;p&gt;Short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook URL shows &lt;code&gt;localhost:5678&lt;/code&gt;: set &lt;code&gt;N8N_WEBHOOK_URL=https://n8n.example.com/&lt;/code&gt; (or &lt;code&gt;WEBHOOK_URL&lt;/code&gt; before n8n 2.30.0) and recreate the container with &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;"Connection lost" in the editor: the proxy has to pass WebSocket upgrades (&lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers over HTTP/1.1) and keep the original &lt;code&gt;Host&lt;/code&gt;. In Nginx Proxy Manager, that's the Websockets Support toggle.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;N8N_PROXY_HOPS=1&lt;/code&gt; for one proxy, so n8n trusts the &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers.&lt;/li&gt;
&lt;li&gt;Test with a Webhook node. Both of its URLs should start with your domain, with no &lt;code&gt;:5678&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why n8n breaks behind a reverse proxy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptoms and causes at a glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Webhook URL starts with &lt;code&gt;http://localhost:5678/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;n8n doesn't know its public URL&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;N8N_WEBHOOK_URL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook URL is &lt;code&gt;https://your-domain:5678/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;N8N_HOST&lt;/code&gt; and &lt;code&gt;N8N_PROTOCOL&lt;/code&gt; set, but no webhook URL&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;N8N_WEBHOOK_URL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Connection lost" in the editor, browser console shows &lt;code&gt;[WebSocketClient] Connection lost, code=...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Proxy isn't passing WebSocket upgrades, or is rewriting the &lt;code&gt;Host&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;Fix the proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs show &lt;code&gt;ERR_ERL_UNEXPECTED_X_FORWARDED_FOR&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;n8n doesn't trust the proxy&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;N8N_PROXY_HOPS=1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login page warns about a secure cookie&lt;/td&gt;
&lt;td&gt;You're on plain HTTP&lt;/td&gt;
&lt;td&gt;See the FAQ&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A minimal n8n Docker Compose file
&lt;/h3&gt;

&lt;p&gt;This is the setup the rest of the article fixes up. It follows the shape of the &lt;a href="https://docs.n8n.io/deploy/host-n8n/install-options/use-a-cloud-provider/use-docker-compose" rel="noopener noreferrer"&gt;official Docker Compose docs&lt;/a&gt;: the image from n8n's own registry, a named volume for &lt;code&gt;/home/node/.n8n&lt;/code&gt;, and the port bound to localhost only, so the proxy is the only way in.&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;n8n&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.n8n.io/n8nio/n8n:2.43.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;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:5678:5678"&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;GENERIC_TIMEZONE=Europe/Berlin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Berlin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true&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;n8n_data:/home/node/.n8n&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;n8n_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin a version tag rather than &lt;code&gt;latest&lt;/code&gt;, so an upgrade only happens when you choose. &lt;code&gt;2.43.4&lt;/code&gt; was the current stable release at the time of testing. Check &lt;a href="https://docs.n8n.io/changelog/release-notes" rel="noopener noreferrer"&gt;n8n's release notes&lt;/a&gt; for newer ones.&lt;/p&gt;

&lt;p&gt;If your proxy also runs in Docker (Nginx Proxy Manager, Traefik, Caddy), put both containers on a shared network so the proxy can reach &lt;code&gt;http://n8n:5678&lt;/code&gt; by service name. You can then drop the &lt;code&gt;ports:&lt;/code&gt; section entirely. The &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt; shows the shared-network setup.&lt;/p&gt;

&lt;p&gt;At idle, n8n used about 690 MiB of RAM on the test box (&lt;code&gt;docker stats&lt;/code&gt;). n8n's docs don't list an official minimum.&lt;/p&gt;

&lt;p&gt;Back up the &lt;code&gt;n8n_data&lt;/code&gt; volume. Per &lt;a href="https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/set-a-custom-encryption-key" rel="noopener noreferrer"&gt;n8n's docs&lt;/a&gt;, n8n generates a random encryption key on first launch, saves it in that folder, and uses it to encrypt your saved credentials. Lose the volume and you lose the key, and with it access to every credential. If you'd rather manage the key yourself, set &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; before the first start.&lt;/p&gt;

&lt;p&gt;n8n is one of the most popular self-hosted automation tools. On October 14, 2026, its &lt;a href="https://github.com/n8n-io/n8n" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; had about 207,300 stars, and the &lt;a href="https://hub.docker.com/r/n8nio/n8n" rel="noopener noreferrer"&gt;&lt;code&gt;n8nio/n8n&lt;/code&gt; image&lt;/a&gt; had been pulled more than 271 million times from Docker Hub. So the fixes below get searched a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: tell n8n its public URL
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Set N8N_WEBHOOK_URL
&lt;/h3&gt;

&lt;p&gt;With the file above, open any Webhook node and the URL is &lt;code&gt;http://localhost:5678/webhook/...&lt;/code&gt;. n8n builds webhook URLs from what it knows about itself, and by default that's &lt;code&gt;localhost&lt;/code&gt;, port 5678, plain HTTP. It can't learn your domain from the proxy for this.&lt;/p&gt;

&lt;p&gt;This is the Webhook node on a default install in the lab:&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%2Fegrxicozirtqj0qj8llv.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%2Fegrxicozirtqj0qj8llv.webp" alt="n8n 2.43.4 Webhook node settings on a default install, with the Test URL shown as http://localhost:5678/webhook-test/deploylark-test" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The common half-fix is to set &lt;code&gt;N8N_HOST&lt;/code&gt; and &lt;code&gt;N8N_PROTOCOL&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_HOST=n8n.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_PROTOCOL=https&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On n8n 2.43.4 that produced &lt;code&gt;https://n8n.example.com:5678/webhook/...&lt;/code&gt; in testing. The internal port leaks into the URL, because n8n doesn't know the proxy is listening on 443.&lt;/p&gt;

&lt;p&gt;The full fix is to set the webhook base URL explicitly:&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GENERIC_TIMEZONE=Europe/Berlin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Berlin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_HOST=n8n.example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_PROTOCOL=https&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_WEBHOOK_URL=https://n8n.example.com/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_PROXY_HOPS=1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then recreate the container (a restart doesn't pick up new env vars):&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook URLs now read &lt;code&gt;https://n8n.example.com/webhook/...&lt;/code&gt;, with no port.&lt;/p&gt;

&lt;p&gt;In the lab, with &lt;code&gt;n8n.lab.test&lt;/code&gt; standing in for the real domain, the Production URL looked like this:&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%2Fjx2ry3y01faztrm0vd7s.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%2Fjx2ry3y01faztrm0vd7s.webp" alt="n8n Webhook node after setting N8N_WEBHOOK_URL, with the Production URL shown as https://n8n.lab.test/webhook/deploylark-test and no port number" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  N8N_WEBHOOK_URL or WEBHOOK_URL?
&lt;/h3&gt;

&lt;p&gt;Most older guides and forum answers say &lt;code&gt;WEBHOOK_URL&lt;/code&gt;. n8n's current docs use &lt;code&gt;N8N_WEBHOOK_URL&lt;/code&gt;. The &lt;a href="https://docs.n8n.io/deploy/host-n8n/install-options/use-a-cloud-provider/use-docker-compose" rel="noopener noreferrer"&gt;Docker Compose page&lt;/a&gt; says it's "available from n8n 2.30.0" and to use &lt;code&gt;WEBHOOK_URL&lt;/code&gt; on earlier versions. The &lt;a href="https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/configure-webhook-urls-with-reverse-proxy" rel="noopener noreferrer"&gt;webhook URL page&lt;/a&gt; says &lt;code&gt;WEBHOOK_URL&lt;/code&gt; "is deprecated from n8n 2.35.0". On 2.43.4, &lt;code&gt;WEBHOOK_URL&lt;/code&gt; still works but prints this at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBHOOK_URL -&amp;gt; Use N8N_WEBHOOK_URL instead, which sets the base URL for both test and production webhooks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: on 2.30.0 or later, use &lt;code&gt;N8N_WEBHOOK_URL&lt;/code&gt;. On anything older, use &lt;code&gt;WEBHOOK_URL&lt;/code&gt;. Don't set both.&lt;/p&gt;

&lt;p&gt;If the editor itself lives at a different URL than the webhooks, there's also &lt;code&gt;N8N_EDITOR_BASE_URL&lt;/code&gt;. For a normal single-domain setup you don't need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: pass WebSockets and the Host header
&lt;/h2&gt;

&lt;p&gt;Other apps with a live UI need the same thing from a proxy. &lt;a href="https://deploylark.com/posts/uptime-kuma-docker-setup/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; and &lt;a href="https://deploylark.com/posts/open-webui-cannot-connect-ollama-docker/" rel="noopener noreferrer"&gt;Open WebUI&lt;/a&gt; both break without WebSocket support.&lt;/p&gt;

&lt;p&gt;"Connection lost" is about the editor's live connection to the server, called the push connection. The browser opens it at &lt;code&gt;/rest/push&lt;/code&gt;. By default it's a WebSocket (&lt;code&gt;N8N_PUSH_BACKEND&lt;/code&gt; defaults to &lt;code&gt;websocket&lt;/code&gt;), so the proxy has to do two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pass the WebSocket upgrade. Forward the &lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers, over HTTP/1.1.&lt;/li&gt;
&lt;li&gt;Keep the original Host. n8n compares the browser's &lt;code&gt;Origin&lt;/code&gt; header with the host the request was sent to. It checks &lt;code&gt;Forwarded&lt;/code&gt;, then &lt;code&gt;X-Forwarded-Host&lt;/code&gt;, then &lt;code&gt;Host&lt;/code&gt;. If they don't match, it rejects the connection.&lt;/li&gt;
&lt;/ol&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%2Fxd9amb8oavunwv3l5zm5.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%2Fxd9amb8oavunwv3l5zm5.webp" alt="Diagram of the request path. The browser sends HTTPS pages and webhooks and the WebSocket at /rest/push to the reverse proxy (NPM or nginx), which forwards plain HTTP to n8n on n8n:5678. The WebSocket leg needs the Upgrade and Connection headers and the original Host header. Webhook URLs come from N8N_WEBHOOK_URL, not from the request. Missing upgrade headers or a rewritten Host make the editor show Connection lost." width="798" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what each broken setup did in testing, so you can match it to your own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Proxy setup&lt;/th&gt;
&lt;th&gt;Result on &lt;code&gt;/rest/push&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nginx Proxy Manager, Websockets Support off&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;401 Unauthorized&lt;/code&gt; (the request reaches n8n as a plain GET, not an upgrade)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nginx with only &lt;code&gt;proxy_pass&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HTTP 500&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nginx with &lt;code&gt;Upgrade&lt;/code&gt;/&lt;code&gt;Connection&lt;/code&gt; headers but no &lt;code&gt;Host&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;Connection opens, then closes with code 1008 and &lt;code&gt;Invalid origin!&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Either proxy configured as below&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the third case, n8n's log spells it out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Origin header does NOT match the expected origin. (Origin: "http://n8n.lab.test" -&amp;gt; "n8n.lab.test", Expected: "n8n:5678" -&amp;gt; "n8n:5678", Protocol: "http")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx sent &lt;code&gt;Host: n8n:5678&lt;/code&gt; (its upstream name) instead of the domain the browser used. n8n saw the mismatch and refused.&lt;/p&gt;

&lt;p&gt;In the browser, that setup left the editor with the "Connection lost" badge in the top right corner:&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%2F6xl2lbwuium1unhvr1ib.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%2F6xl2lbwuium1unhvr1ib.webp" alt="n8n workflow editor with a single Webhook node and a Connection lost warning in the top right corner, loaded through an nginx block that did not pass the Host header" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nginx Proxy Manager
&lt;/h3&gt;

&lt;p&gt;Edit the proxy host and, on the Details tab, turn on Websockets Support. That's the whole fix. NPM already sends the original &lt;code&gt;Host&lt;/code&gt; and the &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers. Use these settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain Names: &lt;code&gt;n8n.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scheme: &lt;code&gt;http&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Forward Hostname / IP: &lt;code&gt;n8n&lt;/code&gt; (the service name, if both are on the same Docker network)&lt;/li&gt;
&lt;li&gt;Forward Port: &lt;code&gt;5678&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Websockets Support: on&lt;/li&gt;
&lt;li&gt;SSL tab: your certificate, with Force SSL on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After turning it on, the push connection through NPM stayed open and the badge went away.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plain nginx
&lt;/h3&gt;

&lt;p&gt;This is the server block that worked in testing. The &lt;code&gt;map&lt;/code&gt; goes in the &lt;code&gt;http {}&lt;/code&gt; context, for example at the top of a file in &lt;code&gt;/etc/nginx/conf.d/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;close&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;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;n8n.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# for HTTPS, use your usual "listen 443 ssl;" and ssl_certificate lines instead&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://n8n:5678&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# or http://127.0.0.1:5678 if nginx runs on the host&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&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;Connection&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&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-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-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_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;3600s&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;&lt;code&gt;proxy_buffering off&lt;/code&gt; keeps streamed responses flowing instead of nginx holding them back. The long &lt;code&gt;proxy_read_timeout&lt;/code&gt; stops nginx from closing an idle push connection after its default 60 seconds.&lt;/p&gt;

&lt;p&gt;Reload nginx (&lt;code&gt;nginx -t &amp;amp;&amp;amp; nginx -s reload&lt;/code&gt;), hard-refresh the editor, and the badge should be gone.&lt;/p&gt;

&lt;p&gt;Through the working server block in the lab, the same workflow loaded with no badge:&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%2F89r0vw9c6dmrzkn7ctvq.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%2F89r0vw9c6dmrzkn7ctvq.webp" alt="The same n8n workflow editor loaded through the corrected nginx server block, with no Connection lost warning in the top bar" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you reach n8n at something like &lt;code&gt;https://n8n.example.com:8443&lt;/code&gt;, the browser's &lt;code&gt;Origin&lt;/code&gt; includes &lt;code&gt;:8443&lt;/code&gt;. If your proxy forwards only the hostname, n8n sees &lt;code&gt;n8n.example.com&lt;/code&gt; vs &lt;code&gt;n8n.example.com:8443&lt;/code&gt; and rejects it with &lt;code&gt;Invalid origin!&lt;/code&gt;. This reproduces through NPM. The simplest fix is to serve n8n on the standard port 443. If you can't, look at what the check compares. n8n's origin check (in its &lt;code&gt;origin-validator&lt;/code&gt; code) compares the Origin's host and port with the forwarded host, so the &lt;code&gt;Host&lt;/code&gt; / &lt;code&gt;X-Forwarded-Host&lt;/code&gt; your proxy sends has to include the port too. In nginx, &lt;code&gt;$http_host&lt;/code&gt; passes on the host exactly as the browser sent it, port included. &lt;code&gt;$host&lt;/code&gt; drops the port.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: trust the proxy, then test a webhook
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Set N8N_PROXY_HOPS
&lt;/h3&gt;

&lt;p&gt;Behind a proxy, log in once and check &lt;code&gt;docker compose logs n8n&lt;/code&gt;. Without &lt;code&gt;N8N_PROXY_HOPS&lt;/code&gt;, this appeared on every login through NPM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false (default). ... ERR_ERL_UNEXPECTED_X_FORWARDED_FOR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;n8n's web server (Express) doesn't trust &lt;code&gt;X-Forwarded-*&lt;/code&gt; headers unless told how many proxies are in front of it. &lt;code&gt;N8N_PROXY_HOPS&lt;/code&gt; defaults to &lt;code&gt;0&lt;/code&gt;. Set it to the number of reverse proxies between the internet and n8n. For one Nginx Proxy Manager or one nginx, that's &lt;code&gt;1&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_PROXY_HOPS=1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that set, the warning went away. If you also have Cloudflare proxying in front of your own proxy, that's two hops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify with a test webhook
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a workflow and add a Webhook trigger. Set HTTP Method to &lt;code&gt;POST&lt;/code&gt; and give it a fixed Path, like &lt;code&gt;deploylark-test&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The node panel has two URLs, Test URL and Production URL. Both should start with &lt;code&gt;https://n8n.example.com/&lt;/code&gt;. If either shows &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;:5678&lt;/code&gt;, go back to Fix 1.&lt;/li&gt;
&lt;li&gt;Test URL. Click Listen for test event (or Execute workflow), then from any machine:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://n8n.example.com/webhook-test/deploylark-test &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'hello=world'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data should show up in the editor right away. If it only shows up after a refresh, the push connection is still broken (Fix 2).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Production URL. Publish the workflow, then:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://n8n.example.com/webhook/deploylark-test &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'hello=world'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the default "Respond: Immediately", the response was &lt;code&gt;{"message":"Workflow was started"}&lt;/code&gt;. Production runs don't appear on the canvas. Check the workflow's Executions tab.&lt;/p&gt;

&lt;p&gt;The two 404s you'll hit while testing mean different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Test URL, not listening: &lt;code&gt;The requested webhook "deploylark-test" is not registered.&lt;/code&gt; with a hint to click Execute workflow first. The test URL only works for one call after each click.&lt;/li&gt;
&lt;li&gt;Production URL, workflow not published: &lt;code&gt;The requested webhook "POST deploylark-test" is not registered.&lt;/code&gt; The workflow must be published (the hint text still says "active" and "toggle", from older versions).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both 404s came through the proxy with the right domain, so they mean the proxy is fine and n8n just isn't listening on that path.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Webhooks and login
&lt;/h3&gt;

&lt;h4&gt;
  
  
  How do you change localhost:5678 in the webhook URL?
&lt;/h4&gt;

&lt;p&gt;Set &lt;code&gt;N8N_WEBHOOK_URL=https://n8n.example.com/&lt;/code&gt; (or &lt;code&gt;WEBHOOK_URL&lt;/code&gt; before n8n 2.30.0) and recreate the container with &lt;code&gt;docker compose up -d&lt;/code&gt;. Setting only &lt;code&gt;N8N_HOST&lt;/code&gt; and &lt;code&gt;N8N_PROTOCOL&lt;/code&gt; gave &lt;code&gt;https://n8n.example.com:5678/&lt;/code&gt;, with the internal port still in it. See Fix 1.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why does the login page say the n8n server is configured to use a secure cookie?
&lt;/h4&gt;

&lt;p&gt;The full message is "Your n8n server is configured to use a secure cookie, however you are either visiting this via an insecure URL, or using Safari." &lt;code&gt;N8N_SECURE_COOKIE&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;, so the login cookie only works over HTTPS. Put a certificate on the proxy and use the &lt;code&gt;https://&lt;/code&gt; URL. Only set &lt;code&gt;N8N_SECURE_COOKIE=false&lt;/code&gt; for a throwaway local test, never on an instance reachable from the internet.&lt;/p&gt;

&lt;h4&gt;
  
  
  Changed the env vars and nothing happened?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;docker compose restart&lt;/code&gt; reuses the old container config. Run &lt;code&gt;docker compose up -d&lt;/code&gt; so Compose recreates the container with the new environment. If you keep the values in a &lt;code&gt;.env&lt;/code&gt; file, Compose only uses them to fill in &lt;code&gt;${...}&lt;/code&gt; placeholders in &lt;code&gt;compose.yaml&lt;/code&gt;, so each variable still needs a line under &lt;code&gt;environment:&lt;/code&gt;. Then check that it took effect:&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;n8n &lt;span class="nb"&gt;printenv&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;N8N_
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Proxies and Cloudflare
&lt;/h3&gt;

&lt;h4&gt;
  
  
  My proxy can't do WebSockets at all. Is there another option?
&lt;/h4&gt;

&lt;p&gt;Yes. Set &lt;code&gt;N8N_PUSH_BACKEND=sse&lt;/code&gt; and n8n uses Server-Sent Events, which is ordinary HTTP streaming. This was tested through Nginx Proxy Manager with Websockets Support off. &lt;code&gt;/rest/push&lt;/code&gt; returned &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt; and stayed open. On plain nginx, the server block above already has &lt;code&gt;proxy_buffering off&lt;/code&gt; and a long &lt;code&gt;proxy_read_timeout&lt;/code&gt;, both sensible for a long-lived stream. WebSockets is n8n's default, so only switch if you have to.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why do you get "Connection lost" behind Cloudflare?
&lt;/h4&gt;

&lt;p&gt;According to &lt;a href="https://developers.cloudflare.com/network/websockets/" rel="noopener noreferrer"&gt;Cloudflare's docs&lt;/a&gt;, proxied WebSockets are supported on all plans, with a WebSockets toggle on the zone's Network page. Make sure it's on. Then check three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If n8n's log shows &lt;code&gt;Invalid origin!&lt;/code&gt;, the host that reaches n8n doesn't match the browser's &lt;code&gt;Origin&lt;/code&gt;. Fix the &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Host&lt;/code&gt; headers as in Fix 2.&lt;/li&gt;
&lt;li&gt;Cloudflare's proxy sits in front of yours, so count it in &lt;code&gt;N8N_PROXY_HOPS&lt;/code&gt; (see Fix 3).&lt;/li&gt;
&lt;li&gt;Cloudflare documents redirect loops with SSL mode "Flexible" in front of an origin that forces HTTPS. The &lt;a href="https://deploylark.com/posts/nginx-proxy-manager-docker-ssl/" rel="noopener noreferrer"&gt;Nginx Proxy Manager guide&lt;/a&gt; covers that loop.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Originally published on DeployLark: &lt;a href="https://deploylark.com/posts/n8n-docker-webhook-url-connection-lost/" rel="noopener noreferrer"&gt;Fix n8n 'Connection Lost' and Webhook URLs Behind a Proxy&lt;/a&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>n8n</category>
      <category>nginx</category>
    </item>
  </channel>
</rss>
