<?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: Shubham Sharma</title>
    <description>The latest articles on DEV Community by Shubham Sharma (@shubham_sharma_94).</description>
    <link>https://dev.to/shubham_sharma_94</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%2F4102464%2F909cc49e-8052-49a0-aa02-87ca32df931c.png</url>
      <title>DEV Community: Shubham Sharma</title>
      <link>https://dev.to/shubham_sharma_94</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shubham_sharma_94"/>
    <language>en</language>
    <item>
      <title>Capstone: Dockerize Your Own App End to End</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:00:01 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/capstone-dockerize-your-own-app-end-to-end-eij</link>
      <guid>https://dev.to/shubham_sharma_94/capstone-dockerize-your-own-app-end-to-end-eij</guid>
      <description>&lt;p&gt;This is the capstone of the Docker Foundations series. Instead of one new concept, it pulls the whole track together: you take a real app from source code to a live URL served over HTTPS, built into a lean image, hardened the way you would actually run it, pushed to a registry by CI, and deployed to a server. Every command below was run for real, first locally and then on an actual Ubuntu server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Get the code: the app, the multi-stage Dockerfile, the production Compose file, the Caddy config, and the CI workflow are in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations/tree/main/10-capstone" rel="noopener noreferrer"&gt;10-capstone folder&lt;/a&gt; of the companion repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Built and run on Docker Engine 29.8.0 with Compose v5.5.1, then deployed on a separate Ubuntu 24.04 server. The output shown is the genuine result.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;The app is deliberately small but real: an Express server backed by Postgres. It serves a page with a visit counter (so it has to read and write a database) and exposes a &lt;code&gt;/healthz&lt;/code&gt; endpoint for health checks. The full source is in the repo; the only thing that matters here is that it is a normal app with a real dependency, not a toy that prints hello.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lean image with a multi-stage build
&lt;/h2&gt;

&lt;p&gt;The Dockerfile builds in two stages. The first installs production dependencies against the lockfile; the second copies just those dependencies and the source into a minimal runtime image that runs as a non-root user and declares a healthcheck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ---- deps: install production dependencies against the lockfile ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app/package.json app/package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="c"&gt;# ---- runtime: minimal image, non-root, with a healthcheck ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app/ ./&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=10s --timeout=3s --start-period=5s --retries=3 \&lt;/span&gt;
  CMD wget -q -O /dev/null http://localhost:3000/healthz || exit 1
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it and check the size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; tdm-capstone:local &lt;span class="nb"&gt;.&lt;/span&gt;
docker images tdm-capstone:local
&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;tdm-capstone:local  233MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the build tooling stays in the first stage, the final image carries only the Alpine base, the production &lt;code&gt;node_modules&lt;/code&gt;, and the app. That is the multi-stage payoff from earlier in the series, applied to a real app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production stack
&lt;/h2&gt;

&lt;p&gt;A single Compose file wires the app to Postgres and puts Caddy in front of it, and it turns on the operating and security practices from the rest of the series at once. The important parts:&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_IMAGE:-tdm-capstone:local}&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="c1"&gt;# do not start until Postgres is ready&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                  &lt;span class="c1"&gt;# the app writes nothing to its own filesystem&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;                          &lt;span class="c1"&gt;# it needs no Linux capabilities&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&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;wget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-O&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/dev/null&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/healthz&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&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.5"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it up, and the health gating sequences the whole stack for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Service}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Status}}"&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;SERVICE   STATUS
app       Up 23 seconds (healthy)
caddy     Up 18 seconds
db        Up 28 seconds (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres becomes healthy first, the app waits for it and then becomes healthy itself, and only then does Caddy start. The app answers over HTTPS through Caddy, and the counter proves it is really talking to Postgres:&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;-k&lt;/span&gt; https://localhost
&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;&amp;lt;!doctype html&amp;gt;...&amp;lt;p&amp;gt;This page has been served &amp;lt;strong&amp;gt;1&amp;lt;/strong&amp;gt; times.&amp;lt;/p&amp;gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit it again and the count goes to 2. Now confirm the hardening actually took effect, not just that it is written in the file. The container runs as an unprivileged user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml &lt;span class="nb"&gt;exec &lt;/span&gt;app &lt;span class="nb"&gt;id&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;uid=1000(node) gid=1000(node) groups=1000(node)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its root filesystem is read-only, so a compromised process cannot rewrite the app, while the &lt;code&gt;/tmp&lt;/code&gt; tmpfs stays writable for scratch space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml &lt;span class="nb"&gt;exec &lt;/span&gt;app sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"touch /oops.txt"&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;touch: /oops.txt: Read-only file system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the limits and dropped capabilities are real, straight from &lt;code&gt;docker inspect&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;ReadonlyRootfs=true  Memory=134217728  NanoCpus=500000000  CapDrop=[ALL]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 128MB of memory, half a CPU, every Linux capability dropped, and a read-only root, on a container that self-reports health. This one stack applies the Compose, volumes, operating, and security posts together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship it: build in CI, push to GHCR
&lt;/h2&gt;

&lt;p&gt;You do not build production images by hand on your laptop. A small GitHub Actions workflow builds the image on every push and pushes it to the GitHub Container Registry, authenticating with the token GitHub gives the job:&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./10-capstone&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/&amp;lt;you&amp;gt;/tdm-capstone:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pushing this to the repo ran the job green in about half a minute and published the image with its digest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build-push  in 26s
pushing manifest for ghcr.io/&amp;lt;you&amp;gt;/tdm-capstone:latest@sha256:d6e8d6b6...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your server can now pull a known, immutable image by tag or digest instead of building on the box. New images published this way are private by default, so to pull one on a server you would first run &lt;code&gt;docker login ghcr.io&lt;/code&gt; (or make the package public in your GitHub package settings).&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy it with automatic HTTPS
&lt;/h2&gt;

&lt;p&gt;The last step is a real server. Copy this folder up, set the environment, and run the same Compose file. The one new idea is the hostname: &lt;a href="https://sslip.io" rel="noopener noreferrer"&gt;sslip.io&lt;/a&gt; is a free wildcard DNS service where a name like &lt;code&gt;203-0-113-5.sslip.io&lt;/code&gt; resolves to &lt;code&gt;203.0.113.5&lt;/code&gt;, which gives you a real hostname for any IP without buying a domain. Caddy uses that hostname to request a certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .env on the server
SITE_ADDRESS=&amp;lt;your-server-ip-with-dashes&amp;gt;.sslip.io
&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 &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
curl &lt;span class="nt"&gt;-k&lt;/span&gt; https://&amp;lt;your-server-ip-with-dashes&amp;gt;.sslip.io
&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;&amp;lt;!doctype html&amp;gt;...&amp;lt;p&amp;gt;This page has been served &amp;lt;strong&amp;gt;1&amp;lt;/strong&amp;gt; times.&amp;lt;/p&amp;gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the app, built from the same Dockerfile, running behind Caddy and answering over HTTPS at a real hostname, on a real server.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;About the certificate: the lab server here has a private IP, which Let's Encrypt cannot reach to validate, so this deploy used Caddy's &lt;code&gt;tls internal&lt;/code&gt; option (a local certificate authority) to prove the HTTPS path end to end. On a real VPS with a public IP, you delete the &lt;code&gt;tls internal&lt;/code&gt; line and Caddy fetches a genuine, browser-trusted Let's Encrypt certificate for your sslip.io hostname automatically, with no domain purchase and no manual certbot step.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Tear it down
&lt;/h2&gt;

&lt;p&gt;Everything is disposable, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.prod.yml down &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That stops and removes the containers, the network, and the named volumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you built
&lt;/h2&gt;

&lt;p&gt;You took an app with a database and turned it into a lean, non-root, resource-limited, health-checked image; ran it as a hardened stack behind a reverse proxy with HTTPS; had CI build and publish it to a registry; and deployed it to a server reachable over HTTPS with no domain purchase. That is the entire Docker Foundations series in one project.&lt;/p&gt;

&lt;p&gt;If you worked through the whole track, from installing Docker and running your first containers, through Compose, lean images, volumes and networks, operating, and security, you now have every piece it takes to ship a container you built yourself. That is a genuinely production-shaped skill set, and everything here is in the companion repo for you to clone and run.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>caddy</category>
      <category>containers</category>
    </item>
    <item>
      <title>Docker Images vs Containers, Explained</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:15:47 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/docker-images-vs-containers-explained-2o0</link>
      <guid>https://dev.to/shubham_sharma_94/docker-images-vs-containers-explained-2o0</guid>
      <description>&lt;p&gt;If you are new to Docker, this is probably the first thing that trips you up: people say "image" and "container" like they mean the same thing, and they absolutely do not. Getting this one distinction straight makes almost everything else about Docker click into place.&lt;/p&gt;

&lt;p&gt;Here is the whole idea in one sentence: an &lt;strong&gt;image&lt;/strong&gt; is the read-only template, and a &lt;strong&gt;container&lt;/strong&gt; is a running copy made from it. If you have written any code, it is the same relationship as a class and an object, or a recipe and the meal you cook from it. One image, many containers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below was run on a real Docker Engine (Server 29.8.0). The image IDs, container IDs, and sizes are the genuine output. The tables are trimmed to the columns that matter here, so what you see on your own machine will have a few more columns.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  One image, many containers
&lt;/h2&gt;

&lt;p&gt;Let's prove the relationship instead of just asserting it. First, pull an image once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull nginx:alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now start three containers from that single image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c1 nginx:alpine
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c2 nginx:alpine
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; c3 nginx:alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is still only one image on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker images nginx
&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;REPOSITORY   TAG      IMAGE ID       SIZE
nginx        alpine   72ba65eb42c1   104MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there are three separate containers, each with its own container ID, all pointing back at that same image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&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;CONTAINER ID   IMAGE          NAMES
f291e8935b87   nginx:alpine   c3
dbbf21ab18e1   nginx:alpine   c2
01da24bab3c5   nginx:alpine   c1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One image (&lt;code&gt;72ba65eb42c1&lt;/code&gt;), three containers (&lt;code&gt;01da...&lt;/code&gt;, &lt;code&gt;dbbf...&lt;/code&gt;, &lt;code&gt;f291...&lt;/code&gt;). That is the core fact: the image is the shared, unchanging original, and each container is an independent running instance of it. This is exactly why Docker is efficient. Ten copies of the same app do not mean ten copies of its files on disk. They share one image.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two quick commands map cleanly onto the two concepts: &lt;code&gt;docker images&lt;/code&gt; lists your images (the templates), and &lt;code&gt;docker ps&lt;/code&gt; lists your running containers (the instances). If you ever lose track of which is which, that pair is the tell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Each container gets its own writable layer
&lt;/h2&gt;

&lt;p&gt;If they all share one read-only image, how can containers be different from each other? Because when a container starts, Docker adds a thin &lt;strong&gt;writable layer&lt;/strong&gt; on top of the image, just for that container. Anything the container changes goes into its own layer and touches nothing else.&lt;/p&gt;

&lt;p&gt;Watch it happen. Write a file inside &lt;code&gt;c1&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;docker &lt;span class="nb"&gt;exec &lt;/span&gt;c1 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"echo 'written inside c1' &amp;gt; /note.txt"&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;c1 &lt;span class="nb"&gt;cat&lt;/span&gt; /note.txt
&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;written inside c1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now look for that same file in &lt;code&gt;c2&lt;/code&gt;, which was started from the identical image:&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;c2 &lt;span class="nb"&gt;cat&lt;/span&gt; /note.txt
&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;cat: can't open '/note.txt': No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file exists in &lt;code&gt;c1&lt;/code&gt; and does not exist in &lt;code&gt;c2&lt;/code&gt;, even though both came from the same image. Each container's changes live in its own private writable layer. The image underneath never changed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That writable layer is created and destroyed with the container. Run &lt;code&gt;docker rm c1&lt;/code&gt; and the &lt;code&gt;note.txt&lt;/code&gt; you wrote is gone for good. This is the number one surprise for beginners: data written inside a container is not permanent. When you need data to survive, you use a volume, which is the subject of its own guide in this series.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So what is an image, really?
&lt;/h2&gt;

&lt;p&gt;An image is not one big blob. It is a stack of read-only layers, each one a set of filesystem changes from the step that built it. You can list them with &lt;code&gt;docker image history&lt;/code&gt;. It prints newest layer first, so here are the top few (the &lt;code&gt;--format&lt;/code&gt; flag just trims it to size and command for readability):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image &lt;span class="nb"&gt;history &lt;/span&gt;nginx:alpine &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"{{.Size}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.CreatedBy}}"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-6&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;51.8MB   RUN /bin/sh -c set -x   &amp;amp;&amp;amp; apkArch="$(cat ...
0B       ENV ACME_VERSION=0.4.1
0B       ENV NJS_RELEASE=1
0B       ENV NJS_VERSION=1.0.1
0B       CMD ["nginx" "-g" "daemon off;"]
0B       STOPSIGNAL SIGQUIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line is a layer. Some add real files and have a size (the 51.8MB package install), others just set metadata like an environment variable or the default command and cost nothing. This is only the top of the list; the base Alpine layer and the rest of the image sit below these, which is where most of the 104MB actually lives. Every container you run from this image shares all of those read-only layers and simply adds its own empty writable layer on top. That shared design is why the three containers above cost you 104MB of image once, not 104MB three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model to keep
&lt;/h2&gt;

&lt;p&gt;Line the two up side by side and the distinction sticks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;image&lt;/strong&gt; is built, versioned, and read-only. You create it with a Dockerfile, tag it, push it to a registry, and pull it. It is the same for everyone who pulls it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;container&lt;/strong&gt; is run, started, stopped, and thrown away. It is one live instance of an image, with its own ID, its own writable layer, and its own lifecycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The commands follow the same split. &lt;code&gt;docker build&lt;/code&gt; and &lt;code&gt;docker pull&lt;/code&gt; deal in images. &lt;code&gt;docker run&lt;/code&gt;, &lt;code&gt;docker stop&lt;/code&gt;, and &lt;code&gt;docker rm&lt;/code&gt; deal in containers. And &lt;code&gt;docker run&lt;/code&gt; is simply the bridge between them: it takes an image and produces a running container.&lt;/p&gt;

&lt;p&gt;Once that lands, the rest of Docker is mostly detail. Next in the Docker Foundations series, put it to work by running your first containers hands-on, then move up to Docker Compose to run several at once.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>containers</category>
    </item>
    <item>
      <title>Fix: Permission Denied on the Docker Daemon Socket</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:15:46 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/fix-permission-denied-on-the-docker-daemon-socket-2jol</link>
      <guid>https://dev.to/shubham_sharma_94/fix-permission-denied-on-the-docker-daemon-socket-2jol</guid>
      <description>&lt;p&gt;You ran a normal &lt;code&gt;docker&lt;/code&gt; command on Linux and got this instead of output:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;On older Docker versions the same problem reads a little differently, but it is the exact same wall:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Nothing is broken. Docker is running fine. Your user account just is not allowed to talk to it yet. This guide shows you why that happens, the one-line fix that gets you working right now, the permanent fix, and the one "fix" you should never copy from a random forum answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below was run on a real Docker Engine (Client 29.8.0 / Server 29.8.0) on Ubuntu 24.04.5 LTS. The commands and output are the genuine results, not illustrations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Docker does its real work in a background service (the daemon) that runs as &lt;code&gt;root&lt;/code&gt;. Your &lt;code&gt;docker&lt;/code&gt; command is just a client. It talks to the daemon through a Unix socket, a special file at &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Look at who owns that 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;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /var/run/docker.sock
&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;srw-rw---- 1 root docker 0 Sep 10 15:41 /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that line right to left. The socket is owned by user &lt;code&gt;root&lt;/code&gt; and group &lt;code&gt;docker&lt;/code&gt;, and its permissions are &lt;code&gt;rw&lt;/code&gt; for the owner, &lt;code&gt;rw&lt;/code&gt; for the group, and nothing for everyone else. So only &lt;code&gt;root&lt;/code&gt; and members of the &lt;code&gt;docker&lt;/code&gt; group can read from or write to it. A brand-new user is in neither:&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;id&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;uid=1000(tester) gid=1000(tester) groups=1000(tester)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;docker&lt;/code&gt; group in that list, so the connection is refused. That is the whole story. The error is a file-permission error wearing a scary costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: run it right now with sudo
&lt;/h2&gt;

&lt;p&gt;If you just need the command to work this second, run it as &lt;code&gt;root&lt;/code&gt; with &lt;code&gt;sudo&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;sudo &lt;/span&gt;docker ps
&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;NAMES     STATUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command connects and returns cleanly (an empty table here just means no containers are running). This is fine for a one-off, but typing &lt;code&gt;sudo&lt;/code&gt; before every &lt;code&gt;docker&lt;/code&gt; command gets old fast, and it means every container you start is being managed as root. For your own machine, set up the permanent fix instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: add your user to the docker group (the real fix)
&lt;/h2&gt;

&lt;p&gt;Add your user to the &lt;code&gt;docker&lt;/code&gt; group once, and you never need &lt;code&gt;sudo&lt;/code&gt; for Docker again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-aG&lt;/code&gt; means "append to this group" (the &lt;code&gt;-a&lt;/code&gt; matters; without it you would replace all your other groups). Confirm it took:&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;id&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;uid=1000(tester) gid=1000(tester) groups=1000(tester),990(docker)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is the &lt;code&gt;docker&lt;/code&gt; group. But if you try &lt;code&gt;docker ps&lt;/code&gt; in the same terminal, you may still get permission denied, and this is the step everyone trips on.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Group membership is only read when a login session starts. Your current shell was started before you joined the group, so it still has your old group list. You need a fresh session for the change to apply.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Get a fresh session in any one of these ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# option A: load the new group into the current shell right now&lt;/span&gt;
newgrp docker

&lt;span class="c"&gt;# option B: log out and back in (or close and reopen your terminal)&lt;/span&gt;

&lt;span class="c"&gt;# option C: over SSH, disconnect and reconnect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, Docker works as your normal user, no &lt;code&gt;sudo&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;docker ps
&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;NAMES     STATUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connected, no permission error, no &lt;code&gt;sudo&lt;/code&gt;. That is the fix you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "fix" to avoid
&lt;/h2&gt;

&lt;p&gt;Search this error and you will find answers telling you to just open up the socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# do NOT do this&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;666 /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It makes the error go away, and it is a genuinely bad idea. &lt;code&gt;666&lt;/code&gt; lets &lt;em&gt;every&lt;/em&gt; user and process on the machine read and write the Docker socket. Anyone who can talk to the Docker daemon can start a container that mounts your entire host filesystem as root, which is effectively handing them root on the box. The &lt;code&gt;docker&lt;/code&gt; group already gives your user that same power, so understand what you are joining.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Error&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a user to the &lt;code&gt;docker&lt;/code&gt; group (or opening the socket) grants root-equivalent access to the whole machine. That is expected and documented, but it means you should only do it for accounts you fully trust. On shared or production servers, prefer rootless Docker or calling Docker through &lt;code&gt;sudo&lt;/code&gt; with a controlled sudoers rule.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# see who owns the socket (root:docker, group-only access)&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /var/run/docker.sock

&lt;span class="c"&gt;# right now, one-off&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps

&lt;span class="c"&gt;# permanent: join the group, then start a fresh session&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
newgrp docker      &lt;span class="c"&gt;# or log out and back in&lt;/span&gt;
docker ps          &lt;span class="c"&gt;# works, no sudo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is every legitimate way out of this error. If &lt;code&gt;sudo docker ps&lt;/code&gt; also fails, your problem is different: the daemon itself is not running. Start it with &lt;code&gt;sudo systemctl start docker&lt;/code&gt; and check &lt;code&gt;sudo systemctl status docker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;New to Docker and want the mental model behind all this? Start with our guide on running your first containers, then work through the Docker Foundations series.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker Foundations: The Complete Hands-On Series (Install to Production)</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Sun, 13 Sep 2026 19:19:28 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/docker-foundations-the-complete-hands-on-series-install-to-production-4c8j</link>
      <guid>https://dev.to/shubham_sharma_94/docker-foundations-the-complete-hands-on-series-install-to-production-4c8j</guid>
      <description>&lt;p&gt;Docker is the front door to modern back-end and self-hosting work, and most tutorials drop you in the middle of it. This series does not. It starts from nothing and takes you all the way to an app you built, hardened, and deployed over HTTPS on a real server. Every guide is hands-on, and every command and number in them was run on real Docker and captured, not paraphrased.&lt;/p&gt;

&lt;p&gt;Use this page as the map. Work straight down it if you are starting out, or jump to the piece you need.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every guide in this series is a real lab: the commands, output, image sizes, and errors are genuine, captured on Docker Engine 29.x. The runnable code for each one lives in the companion repo linked at the bottom.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The path, start to finish
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;a href="https://www.techdevmantra.com/guides/install-docker-macos-windows-wsl2-linux" rel="noopener noreferrer"&gt;Install Docker on macOS, Windows (WSL2), and Linux&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
One guide, three operating systems, ending in a verified &lt;code&gt;docker run hello-world&lt;/code&gt; and the post-install checks each OS actually needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;a href="https://www.techdevmantra.com/guides/run-your-first-docker-containers" rel="noopener noreferrer"&gt;Run Your First Containers&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
The everyday lifecycle on a real nginx: &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;, and &lt;code&gt;rm&lt;/code&gt;, plus the images-versus-containers mental model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;a href="https://www.techdevmantra.com/guides/docker-compose-multi-service-stack" rel="noopener noreferrer"&gt;Docker Compose: Run a Multi-Service Stack&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Move from single containers to a declarative Web plus Postgres plus Redis stack with one &lt;code&gt;docker compose up&lt;/code&gt;, wired together over a Compose network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;a href="https://www.techdevmantra.com/guides/lean-docker-images-multi-stage-builds" rel="noopener noreferrer"&gt;Lean Docker Images: Multi-Stage Builds and Layer Caching&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Cut the same app from a 1.62GB image to 233MB with a multi-stage build, and measure exactly why it shrank and why rebuilds get faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;a href="https://www.techdevmantra.com/guides/docker-volumes-bind-mounts-networks" rel="noopener noreferrer"&gt;Where Your Data Lives: Volumes, Bind Mounts, and Networks&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Make data survive restarts, back up and restore a volume, and connect containers by name on a user-defined network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. &lt;a href="https://www.techdevmantra.com/guides/operating-docker-containers" rel="noopener noreferrer"&gt;Operating Containers: Healthchecks, Limits, Restart Policies, and Env Config&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Turn a working stack into one that behaves in production: health-gated startup, memory and CPU limits, self-healing restarts, and clean env config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. &lt;a href="https://www.techdevmantra.com/guides/docker-security-basics" rel="noopener noreferrer"&gt;Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Harden a container without breaking it: scan for CVEs, run as non-root, mount the root filesystem read-only, drop capabilities, and keep secrets out of images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. &lt;a href="https://www.techdevmantra.com/guides/docker-vs-podman" rel="noopener noreferrer"&gt;Docker vs Podman: A Hands-On Comparison and Migration&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Run the same images and Compose file under both engines to see what daemonless and rootless really change, and what it takes to migrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. &lt;a href="https://www.techdevmantra.com/guides/dockerize-app-end-to-end" rel="noopener noreferrer"&gt;Capstone: Dockerize Your Own App End to End&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Put it all together: a lean image, a hardened Compose stack, a CI build that pushes to a registry, and a deploy behind Caddy with automatic HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;Two short reads that clear up the questions almost everyone hits early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.techdevmantra.com/guides/docker-images-vs-containers-explained" rel="noopener noreferrer"&gt;Docker Images vs Containers, Explained&lt;/a&gt;&lt;/strong&gt; proves the difference with real output: one image, many containers, each with its own writable layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.techdevmantra.com/guides/fix-permission-denied-docker-daemon-socket" rel="noopener noreferrer"&gt;Fix: Permission Denied on the Docker Daemon Socket&lt;/a&gt;&lt;/strong&gt; explains the error every Linux beginner sees and the one permanent fix (plus the insecure one to avoid).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Get the code: every runnable lab in this series lives in the &lt;a href="https://github.com/Ssharma94Eie/docker-foundations" rel="noopener noreferrer"&gt;docker-foundations repo&lt;/a&gt;, one folder per post. Clone it and follow along.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;If you are new, begin at step 1 and go in order; each post builds on the last. If you already run containers, jump to Compose, security, or the capstone. By the end you will be able to build a lean image, run it as a hardened stack, and ship it to a server over HTTPS, all with tools you understand because you ran every step yourself.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosting</category>
      <category>containers</category>
      <category>linux</category>
    </item>
    <item>
      <title>LM Studio Guide: Run Local LLMs on Your Mac Fast</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:56:57 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/lm-studio-guide-run-local-llms-on-your-mac-fast-2g70</link>
      <guid>https://dev.to/shubham_sharma_94/lm-studio-guide-run-local-llms-on-your-mac-fast-2g70</guid>
      <description>&lt;p&gt;LLMs are rapidly changing how we interact with technology, and thankfully, you don't need a supercomputer or a cloud subscription to experiment with them. LM Studio is a desktop application that lets you download and run a vast array of large language models right on your Mac. This guide will walk you through everything from understanding why you'd want to run models locally to getting your first AI model up and running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is LM Studio and Why Run AI Models on Your Mac
&lt;/h2&gt;

&lt;p&gt;LM Studio is a game-changer for anyone curious about AI. It's a beautifully designed, user-friendly desktop application that simplifies the process of downloading, discovering, and running large language models (LLMs) directly on your computer. Think of it as an app store for AI, but instead of games or productivity tools, you're downloading powerful AI models that can generate text, write code, answer questions, and much more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privacy and Control Without the Cloud
&lt;/h3&gt;

&lt;p&gt;One of the biggest advantages of running AI models locally with LM Studio is the unparalleled privacy and control you gain. When you use cloud-based AI services, your prompts and data are sent to remote servers, where they might be stored, analyzed, or used for training. With LM Studio, everything happens on your machine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Running models locally means your conversations, your code snippets, and your sensitive data never leave your computer. This is a massive win for privacy-conscious users and developers who handle proprietary information.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This local execution also means you're not reliant on external APIs that can change their terms, pricing, or availability without notice. You have direct access to the AI's capabilities whenever you need them, internet connection or not.&lt;/p&gt;

&lt;h3&gt;
  
  
  What You'll Be Able to Do After Setup
&lt;/h3&gt;

&lt;p&gt;Once LM Studio is up and running, a whole new world of AI-powered possibilities opens up on your Mac. You're not just running a demo; you're interacting with powerful AI that can assist you in numerous ways.&lt;/p&gt;

&lt;p&gt;Here are just a few things you'll be able to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Text Generation:&lt;/strong&gt; Brainstorm ideas, write blog posts, draft emails, create marketing copy, or even write poetry.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Code Completion:&lt;/strong&gt; Get intelligent code suggestions, help with debugging, and understand complex code snippets, all within your local environment.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Chatbot Creation:&lt;/strong&gt; Build your own personal chatbot for specific tasks or general conversation, trained on the models you download.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Summarization:&lt;/strong&gt; Condense long documents or articles into concise summaries.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Translation:&lt;/strong&gt; Experiment with language translation capabilities.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Creative Writing:&lt;/strong&gt; Develop stories, scripts, or game narratives.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The beauty of LM Studio is its flexibility. You can swap out models easily, experiment with different AI personalities, and tailor the experience to your exact needs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Check Your Mac's Readiness Before Installing
&lt;/h2&gt;

&lt;p&gt;Before you dive into downloading LM Studio, it's crucial to ensure your Mac is up to the task. Running large language models, even locally, can be resource-intensive. Understanding your system's capabilities will help you set realistic expectations and avoid potential performance issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimum vs. Recommended Specs
&lt;/h3&gt;

&lt;p&gt;The performance of AI models heavily depends on your hardware. While LM Studio can technically run on a range of Macs, some configurations will offer a much smoother experience than others.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Minimum Specs (Basic Functionality)&lt;/th&gt;
&lt;th&gt;Recommended Specs (Smooth Experience)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;td&gt;16 GB or more&lt;/td&gt;
&lt;td&gt;More RAM allows for larger models and faster processing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50 GB free space&lt;/td&gt;
&lt;td&gt;100 GB+ free space&lt;/td&gt;
&lt;td&gt;Models can be several gigabytes each.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Processor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Intel Core i5 or Apple M1 (base)&lt;/td&gt;
&lt;td&gt;Apple M1 Pro/Max/Ultra, M2, M3 series (or higher Intel)&lt;/td&gt;
&lt;td&gt;Apple Silicon (M-series) chips offer significant performance advantages.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Integrated Graphics&lt;/td&gt;
&lt;td&gt;Dedicated GPU (if available) or Apple Silicon GPU&lt;/td&gt;
&lt;td&gt;GPU acceleration dramatically speeds up inference. LM Studio leverages Metal on macOS.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How to Check Your Mac's Specs
&lt;/h3&gt;

&lt;p&gt;You don't need to be a terminal wizard to find out what kind of Mac you have. Here's a simple way to check your system details:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Click the Apple Menu:&lt;/strong&gt; In the top-left corner of your screen, click the Apple icon ().&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Select "About This Mac":&lt;/strong&gt; This will open a window with a summary of your Mac's hardware and software.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Check Overview Tab:&lt;/strong&gt; Look for information on your "Processor" (e.g., Apple M1, Intel Core i7), "Memory" (your RAM), and "Graphics."&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Check Storage:&lt;/strong&gt; Click on the "Storage" tab to see how much free space you have.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;For More Detail:&lt;/strong&gt; Click "System Report..." for a more in-depth look at your hardware.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your Mac has Apple Silicon (M1, M2, M3 chips), you're in a great position. These chips are highly optimized for AI tasks and will generally provide a much better experience than older Intel Macs with equivalent RAM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Download and Install LM Studio on Your Mac
&lt;/h2&gt;

&lt;p&gt;Now that you've confirmed your Mac is ready, let's get LM Studio installed. The process is straightforward and designed to be as user-friendly as possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting the Installer from the Official Source
&lt;/h3&gt;

&lt;p&gt;Always download software from official sources to ensure you're getting a legitimate and malware-free copy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Head over to the official LM Studio website: &lt;a href="https://lmstudio.ai/" rel="noopener noreferrer"&gt;lmstudio.ai&lt;/a&gt;. You'll find download buttons prominently displayed. Choose the version appropriate for your Mac (usually an &lt;code&gt;.dmg&lt;/code&gt; file).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Running the Installation Wizard
&lt;/h3&gt;

&lt;p&gt;Once you've downloaded the &lt;code&gt;.dmg&lt;/code&gt; file, the installation is usually as simple as dragging the application icon to your Applications folder.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Open the &lt;code&gt;.dmg&lt;/code&gt; file:&lt;/strong&gt; Double-click the downloaded file. A Finder window will typically appear.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Drag to Applications:&lt;/strong&gt; Drag the LM Studio application icon into the "Applications" folder shortcut within that window.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Eject the disk image:&lt;/strong&gt; After copying, you can eject the LM Studio disk image by dragging its icon from the Finder sidebar to the Trash.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Launch LM Studio:&lt;/strong&gt; Open your Applications folder and double-click the LM Studio icon. You might see a security warning; if so, click "Open."&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Verify Installation and Launch for the First Time
&lt;/h3&gt;

&lt;p&gt;After launching, LM Studio will present its main interface. For the first launch, it might take a moment to load. You'll see a clean dashboard with options to search for models, chat, and view settings. This confirms that the installation was successful and LM Studio is ready to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download Your First AI Model Inside LM Studio
&lt;/h2&gt;

&lt;p&gt;The real magic of LM Studio is its integrated model browser. Instead of hunting for models across the web, you can discover and download them directly within the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding Model Sizes and What They Mean
&lt;/h3&gt;

&lt;p&gt;You'll see models listed with numbers like "7B," "13B," or "70B." This refers to the number of parameters the model has, which is a rough indicator of its complexity and capability. Larger models are generally more powerful but require more resources (RAM and processing power).&lt;/p&gt;

&lt;p&gt;You'll also encounter terms like "quantization." This is a process that reduces the precision of the model's weights, making it smaller and faster to run, often with a negligible impact on quality. Common quantization formats include GGUF (used by llama.cpp, which LM Studio leverages) with variations like &lt;code&gt;q4_K_M&lt;/code&gt; or &lt;code&gt;q5_K_S&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model Size&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Resource Needs&lt;/th&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7B&lt;/td&gt;
&lt;td&gt;7 Billion&lt;/td&gt;
&lt;td&gt;Low to Moderate&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Beginners, basic tasks, systems with 8-16GB RAM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13B&lt;/td&gt;
&lt;td&gt;13 Billion&lt;/td&gt;
&lt;td&gt;Moderate to High&lt;/td&gt;
&lt;td&gt;Very Good&lt;/td&gt;
&lt;td&gt;Systems with 16GB+ RAM, more complex tasks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70B&lt;/td&gt;
&lt;td&gt;70 Billion&lt;/td&gt;
&lt;td&gt;Very High&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;High-end systems with 32GB+ RAM, demanding tasks.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;For your first model, start small. A 7B or 13B model is usually a safe bet for most modern Macs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Recommended Starter Models for Mac
&lt;/h3&gt;

&lt;p&gt;Here are a few models that are often well-regarded and perform nicely on Mac hardware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Mistral 7B Instruct:&lt;/strong&gt; A very capable 7B model that balances performance and quality.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Llama 3 8B Instruct:&lt;/strong&gt; Meta's latest offering, known for its strong performance and instruction-following.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;OpenHermes 2.5 Mistral 7B:&lt;/strong&gt; A fine-tuned version of Mistral, often praised for its conversational abilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Downloading a Model Step by Step
&lt;/h3&gt;

&lt;p&gt;Let's get your first model downloaded:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Navigate to the Search Tab:&lt;/strong&gt; In LM Studio, click the magnifying glass icon on the left sidebar to go to the model search.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Search for a Model:&lt;/strong&gt; Type the name of a model (e.g., "Mistral 7B Instruct") into the search bar.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Select a Specific Version:&lt;/strong&gt; You'll see a list of available GGUF files. Look for one with a &lt;code&gt;Q&lt;/code&gt; in its name (e.g., &lt;code&gt;mistral-7b-instruct-v0.2.Q4_K_M.gguf&lt;/code&gt;). The &lt;code&gt;Q4_K_M&lt;/code&gt; indicates a good balance of size and quality.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Click Download:&lt;/strong&gt; Click the download button next to the model file you've chosen. You'll see the download progress in the bottom section of the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Model downloads can take a while depending on your internet speed and the model size. Be patient!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Load Your Model and Start Chatting
&lt;/h2&gt;

&lt;p&gt;With a model downloaded, the next step is to load it and start interacting. LM Studio makes this incredibly simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading the Model into Memory
&lt;/h3&gt;

&lt;p&gt;Once the download is complete, you need to load the model into LM Studio's inference engine.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Go to the Chat Tab:&lt;/strong&gt; Click the chat bubble icon on the left sidebar.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Select Your Model:&lt;/strong&gt; At the top of the chat interface, you'll see a dropdown menu labeled "Select a model to load." Click it and choose the model you just downloaded.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Wait for Loading:&lt;/strong&gt; LM Studio will now load the model into your Mac's RAM. You'll see a progress indicator. This can take anywhere from a few seconds to a couple of minutes, depending on the model size and your system's speed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Your First Prompt and Response
&lt;/h3&gt;

&lt;p&gt;Once the model is loaded, the chat interface is ready. Simply type your question or prompt into the message box at the bottom and press Enter. The model will then generate a response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic Settings to Tweak (Temperature, Context)
&lt;/h3&gt;

&lt;p&gt;You'll notice a settings panel on the right side of the chat interface. While you can explore these later, two key parameters to be aware of are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Temperature:&lt;/strong&gt; Controls the randomness of the output. Lower temperatures (e.g., 0.2) lead to more focused and deterministic responses, while higher temperatures (e.g., 0.8) produce more creative and varied output.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Context Length:&lt;/strong&gt; This determines how much previous conversation the model remembers. A larger context window allows for longer, more coherent conversations but uses more RAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here are some common settings to experiment with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Temperature:&lt;/strong&gt; Start around 0.7 for general chat.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Top-K / Top-P:&lt;/strong&gt; These are sampling strategies that also influence output creativity. Defaults are often fine to start.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Max new tokens:&lt;/strong&gt; Limits the length of the model's response.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Context Length:&lt;/strong&gt; Adjust based on your RAM. For smaller models, you might be able to increase this significantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't be afraid to experiment! Changing these settings can dramatically alter the model's behavior.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Installation and Setup Issues
&lt;/h2&gt;

&lt;p&gt;Even with user-friendly tools, you might run into a snag or two. Here are solutions to some common problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  App Won't Launch or Crashes on Startup
&lt;/h3&gt;

&lt;p&gt;If LM Studio refuses to open or quits unexpectedly right after launching, it's often due to permissions or installation conflicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting App Launch Issues&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Re-download LM Studio:&lt;/strong&gt; The installer might have been corrupted during download. Try downloading it again from lmstudio.ai.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Check macOS Version:&lt;/strong&gt; Ensure your macOS is up-to-date. LM Studio has minimum OS requirements.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Delete and Reinstall:&lt;/strong&gt; Drag LM Studio from your Applications folder to the Trash, then empty the Trash. Re-download and install it again.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Permissions:&lt;/strong&gt; While less common for simple drag-and-drop installs, ensure LM Studio has necessary permissions if prompted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Model Downloads Fail or Run Out of Disk Space
&lt;/h3&gt;

&lt;p&gt;This is usually a straightforward storage issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting Download Failures&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Check Available Disk Space:&lt;/strong&gt; Go to "About This Mac" -&amp;gt; "Storage" to see how much free space you have. Models can be several gigabytes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pause and Resume:&lt;/strong&gt; Sometimes, pausing the download and then resuming it can help.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Clear Cache:&lt;/strong&gt; LM Studio has a cache for downloaded models. You can manage this in the Settings tab to free up space if needed.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Download Smaller Models:&lt;/strong&gt; If space is tight, opt for smaller models or more heavily quantized versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Responses Are Slow or Model Won't Load
&lt;/h3&gt;

&lt;p&gt;Performance issues are typically related to hardware limitations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting Slow Performance&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;RAM is Key:&lt;/strong&gt; If the model won't load or responses are extremely slow, you might not have enough RAM for that specific model.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Close Other Applications:&lt;/strong&gt; Free up RAM by closing any unnecessary applications running in the background.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Try a Smaller Model:&lt;/strong&gt; A 7B model will always be faster and easier to load than a 70B model.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GPU Acceleration:&lt;/strong&gt; Ensure GPU acceleration is enabled in the settings if your Mac supports it (which most modern Macs with Apple Silicon do). LM Studio usually handles this automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Q: Why is my model download so slow?&lt;br&gt;
A: Download speed depends heavily on your internet connection and the server load on the model hosting platform. Larger models naturally take longer.&lt;/p&gt;

&lt;p&gt;Q: Can I run multiple models at once?&lt;br&gt;
A: LM Studio is designed to run one model for inference at a time. You can download many models, but only one can be loaded into memory for chatting.&lt;/p&gt;

&lt;p&gt;Q: What's the difference between GGUF and other model formats?&lt;br&gt;
A: GGUF is a file format optimized for running LLMs efficiently on consumer hardware, particularly using libraries like llama.cpp, which LM Studio relies on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Build and Do Next with LM Studio
&lt;/h2&gt;

&lt;p&gt;You've got LM Studio installed, a model downloaded, and you've had your first chat. What's next? LM Studio isn't just for casual chatting; it's a powerful tool for developers and creators.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text Generation and Content Creation
&lt;/h3&gt;

&lt;p&gt;Leverage LM Studio for all your writing needs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Brainstorming:&lt;/strong&gt; Ask for blog post ideas, marketing slogans, or story concepts.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Drafting:&lt;/strong&gt; Generate first drafts of articles, emails, or social media posts.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Editing:&lt;/strong&gt; Get suggestions for improving clarity, tone, or grammar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Code Completion and Programming Assistance
&lt;/h3&gt;

&lt;p&gt;Developers will find LM Studio invaluable for local coding tasks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Code Snippets:&lt;/strong&gt; Ask for boilerplate code or examples for specific functions.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Debugging Help:&lt;/strong&gt; Paste error messages or code blocks and ask for potential explanations or fixes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Learning New Languages:&lt;/strong&gt; Request explanations of syntax or concepts in a programming language you're learning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Building Chatbots and Conversational Interfaces
&lt;/h3&gt;

&lt;p&gt;LM Studio can act as the backend for your own applications.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Local API:&lt;/strong&gt; LM Studio can expose a local OpenAI-compatible API. This means you can point your existing AI applications or scripts to LM Studio instead of a cloud service.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Custom Tools:&lt;/strong&gt; Build specialized chatbots for customer support, internal knowledge bases, or personal assistants.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Experimenting with Prompt Engineering
&lt;/h3&gt;

&lt;p&gt;The quality of AI output is heavily influenced by how you prompt it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Iterate:&lt;/strong&gt; Try different phrasings, add context, or specify the desired output format.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Few-Shot Learning:&lt;/strong&gt; Provide examples within your prompt to guide the model's response style.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Role-Playing:&lt;/strong&gt; Instruct the model to act as a specific persona for tailored responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Success&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By running these models locally, you're not just experimenting with AI; you're building a foundation for powerful, privacy-preserving applications.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Ready to Go Deeper with LM Studio
&lt;/h2&gt;

&lt;p&gt;You've successfully set up LM Studio and run your first AI model. This is just the beginning of your journey into local AI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explore More AI Possibilities&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ready to dive deeper? Explore LM Studio's documentation to learn about advanced settings, custom model configurations, and integrating with other tools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://lmstudio.ai/docs/" rel="noopener noreferrer"&gt;Discover Advanced Features&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/models" rel="noopener noreferrer"&gt;Start experimenting with different models&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/docs/" rel="noopener noreferrer"&gt;Read the official LM Studio documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lmstudio.ai/discord" rel="noopener noreferrer"&gt;Join the LM Studio Discord community&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>lmstudio</category>
      <category>localai</category>
      <category>aitools</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Running Local LLMs with RamaLama and Docker on a Mac: A Hands-On Guide</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:24:50 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/running-local-llms-with-ramalama-and-docker-on-a-mac-a-hands-on-guide-140p</link>
      <guid>https://dev.to/shubham_sharma_94/running-local-llms-with-ramalama-and-docker-on-a-mac-a-hands-on-guide-140p</guid>
      <description>&lt;p&gt;RamaLama runs large language models as OCI containers, so a single command (&lt;code&gt;ramalama run smollm:135m&lt;/code&gt;) pulls a model and starts talking to it, with no Python environment to babysit. I spent an afternoon putting it through its paces on an Apple Silicon Mac (Apple M4 Pro, 48 GB RAM, macOS 26.6) with Docker 29.4 provided by OrbStack. This guide is what I actually saw: the install, the first model, an OpenAI-compatible server, and the one macOS-specific catch that isn't obvious from the docs. Every command and number below is from that run, on RamaLama 0.24.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is RamaLama?
&lt;/h2&gt;

&lt;p&gt;RamaLama is an open-source CLI from the container-tooling community that treats models like container images. Instead of assembling an inference stack yourself, it pulls a hardened OCI image containing llama.cpp (or vLLM/MLX) plus your chosen model and runs it with Podman or Docker. If you've used Ollama the ergonomics feel familiar (&lt;code&gt;run&lt;/code&gt;, &lt;code&gt;serve&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;), but the runtime and model live inside containers you can inspect and sign, and weights come straight from Hugging Face, Ollama, or any OCI registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing RamaLama on macOS
&lt;/h2&gt;

&lt;p&gt;With Homebrew it's one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;ramalama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pulled RamaLama 0.24.0 and, notably, its own copy of &lt;code&gt;llama.cpp&lt;/code&gt;, &lt;code&gt;ggml&lt;/code&gt;, and &lt;code&gt;libomp&lt;/code&gt; as dependencies. Hold onto that detail; it matters for GPU acceleration later. Confirm the install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama version
&lt;span class="c"&gt;# ramalama version 0.24.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need a container engine running. I used Docker through OrbStack; Podman works too and is RamaLama's default on Linux.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running your first model
&lt;/h2&gt;

&lt;p&gt;The headline command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama run smollm:135m &lt;span class="s2"&gt;"In one sentence, what is a Linux container?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing a prompt as an argument gives you one-shot output instead of dropping into a chat REPL. On first run this pulled the RamaLama container image, downloaded the model, and answered. &lt;code&gt;smollm:135m&lt;/code&gt; resolves to &lt;code&gt;hf://HuggingFaceTB/smollm-135M-instruct-v0.2-Q8_0-GGUF&lt;/code&gt;, a 138 MB, 8-bit quantized GGUF from Hugging Face.&lt;/p&gt;

&lt;p&gt;First-run wall-clock was 2 minutes 56 seconds, but almost all of that was downloads (the ~1 GB image plus the model); the 135M model itself is near-instant on CPU. It is also not smart: asked about containers it invented "2048-bit containers" and a &lt;code&gt;docker-compose up -v&lt;/code&gt; command that doesn't exist. That's expected at 135M parameters. Use a model this small to validate your setup, not to do real work; a 1B model like &lt;code&gt;llama3.2:1b&lt;/code&gt; (a 770 MB Q4_K_M download) answers the same question correctly. (For which models are actually worth running today, see the &lt;a href="https://www.techdevmantra.com/news/open-weight-model-cracks-webdev-leaderboard-top-3" rel="noopener noreferrer"&gt;open-weight coding leaderboard shake-up&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Check what you've downloaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama list
&lt;span class="c"&gt;# SHORTNAME    NAME                                                    SIZE&lt;/span&gt;
&lt;span class="c"&gt;# llama3.2:1b  hf://bartowski/Llama-3.2-1B-Instruct-GGUF               770.28 MB&lt;/span&gt;
&lt;span class="c"&gt;# smollm:135m  hf://HuggingFaceTB/smollm-135M-instruct-v0.2-Q8_0-GGUF  138.1 MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Models live under &lt;code&gt;~/.local/share/ramalama&lt;/code&gt;, separate from your container images.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RamaLama actually runs
&lt;/h2&gt;

&lt;p&gt;Before running anything for real, &lt;code&gt;--dryrun&lt;/code&gt; prints the exact command without executing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama &lt;span class="nt"&gt;--dryrun&lt;/span&gt; run smollm:135m &lt;span class="s2"&gt;"hi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my Mac that expands to a hardened &lt;code&gt;docker run&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;docker run ... &lt;span class="nt"&gt;--security-opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disable &lt;span class="nt"&gt;--cap-drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no-new-privileges &lt;span class="nt"&gt;--pull&lt;/span&gt; always &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--init&lt;/span&gt; quay.io/ramalama/ramalama:0.24 &lt;span class="se"&gt;\&lt;/span&gt;
  llama-server &lt;span class="nt"&gt;--host&lt;/span&gt; :: &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="nt"&gt;--model&lt;/span&gt; /path/to/model &lt;span class="nt"&gt;--threads&lt;/span&gt; 7 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what it does by default: drops all Linux capabilities, disables privilege escalation, and starts &lt;code&gt;llama-server&lt;/code&gt;, the same server that backs the OpenAI-compatible API below. The base image (&lt;code&gt;quay.io/ramalama/ramalama:0.24&lt;/code&gt;) is about 1 GB, downloaded once and reused.&lt;/p&gt;

&lt;h2&gt;
  
  
  The macOS gotcha: containers run on the CPU
&lt;/h2&gt;

&lt;p&gt;Here is the part that trips people up. On Apple Silicon, a model running inside a Linux container cannot reach the Mac's GPU, because Docker's Linux VM has no path to Metal. &lt;code&gt;ramalama info&lt;/code&gt; reports the container engine's accelerator as &lt;code&gt;none&lt;/code&gt;:&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Accelerator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"runtimes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"llama_cpp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mlx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="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;So the default containerized run is CPU-only. Fine for a 135M toy, painful for anything larger. The fix is &lt;code&gt;--nocontainer&lt;/code&gt;, which runs the host's llama.cpp (the copy Homebrew installed) directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama &lt;span class="nt"&gt;--nocontainer&lt;/span&gt; serve &lt;span class="nt"&gt;-p&lt;/span&gt; 8081 llama3.2:1b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I benchmarked the difference on the same model and prompt. Served natively, Llama-3.2-1B (Q4_K_M) loads straight onto the Apple GPU. Its startup log shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;load_tensors: offloaded 17/17 layers to GPU
ggml_metal_init: found device: Apple M4 Pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and it generated at ~206 tokens/sec. The same model served in the default container has no GPU to offload to and ran at ~102 tokens/sec on the CPU, about half the speed on this M4 Pro. RamaLama also exposes an &lt;code&gt;mlx&lt;/code&gt; runtime if you'd rather use Apple's own inference framework than llama.cpp.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;Acceleration&lt;/th&gt;
&lt;th&gt;Llama-3.2-1B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container (default)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ramalama run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full (OCI, cap-drop)&lt;/td&gt;
&lt;td&gt;CPU only&lt;/td&gt;
&lt;td&gt;~102 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ramalama --nocontainer run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Apple GPU (Metal) / MLX&lt;/td&gt;
&lt;td&gt;~206 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade-off is genuine: containers give you isolation and reproducibility; native gives you the GPU. On a Mac doing real work, &lt;code&gt;--nocontainer&lt;/code&gt; is usually what you want. On Linux with an NVIDIA GPU, the container path keeps both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving an OpenAI-compatible API
&lt;/h2&gt;

&lt;p&gt;This is where RamaLama earns its place. &lt;code&gt;serve&lt;/code&gt; starts the same &lt;code&gt;llama-server&lt;/code&gt; as a local endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama serve &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; tdm-lab &lt;span class="nt"&gt;-p&lt;/span&gt; 8080 smollm:135m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It speaks the OpenAI API, so anything that talks to OpenAI can point at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8080/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&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;'{"model":"smollm","messages":[{"role":"user","content":"Say hello."}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is standard OpenAI JSON: &lt;code&gt;choices[].message.content&lt;/code&gt;, a &lt;code&gt;usage&lt;/code&gt; block, and &lt;code&gt;timings&lt;/code&gt;. Swap the base URL in your existing OpenAI client and your app runs locally with no code changes. Stop it when done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ramalama stop tdm-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather wire a local endpoint into your editor, the same idea powers our guide on &lt;a href="https://www.techdevmantra.com/guides/vs-codes-github-copilot-chat-lm-studio-local-api-for-offline-coding" rel="noopener noreferrer"&gt;connecting Copilot Chat to a local API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, is RamaLama worth it?
&lt;/h2&gt;

&lt;p&gt;If you already live in containers, yes. Its strengths are the security defaults (cap-drop, no-new-privileges, signed OCI images), pulling from Hugging Face, Ollama, and OCI registries interchangeably, and the zero-friction OpenAI server. If you just want the fastest local chat on a Mac with a GUI, &lt;a href="https://www.techdevmantra.com/guides/lm-studio-guide-run-local-llms-on-macs" rel="noopener noreferrer"&gt;LM Studio&lt;/a&gt; is gentler. The two aren't mutually exclusive: I keep LM Studio for exploring and RamaLama for scripting reproducible, servable model runs.&lt;/p&gt;

&lt;p&gt;Plan for two things before you graduate from the toy model. Pick a real quantized model that fits your RAM (a 7–8B Q4 model wants roughly 6–8 GB free), and on a Mac decide up front whether you're optimizing for isolation (container, CPU) or speed (native, Apple GPU).&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;brew install ramalama&lt;/code&gt; (bundles llama.cpp); needs Docker or Podman running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run:&lt;/strong&gt; &lt;code&gt;ramalama run &amp;lt;model&amp;gt; "prompt"&lt;/code&gt; for one-shot output; models come from Hugging Face, Ollama, or OCI registries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect first:&lt;/strong&gt; &lt;code&gt;ramalama --dryrun run &amp;lt;model&amp;gt;&lt;/code&gt; prints the exact hardened &lt;code&gt;docker run&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS catch:&lt;/strong&gt; containerized runs are CPU-only; &lt;code&gt;--nocontainer&lt;/code&gt; offloads to the Apple GPU (Metal). On this M4 Pro, Llama-3.2-1B ran ~206 tok/s native versus ~102 tok/s in-container, about 2x faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve:&lt;/strong&gt; &lt;code&gt;ramalama serve&lt;/code&gt; exposes a drop-in OpenAI-compatible API on port 8080.&lt;/li&gt;
&lt;li&gt;Tested with RamaLama 0.24.0 on macOS 26.6 (Apple M4 Pro, 48 GB), Docker 29.4 via OrbStack; models smollm:135m and llama3.2:1b (Q4_K_M).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Put SSH Behind Tailscale and Close Port 22</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:11:49 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/put-ssh-behind-tailscale-and-close-port-22-1a52</link>
      <guid>https://dev.to/shubham_sharma_94/put-ssh-behind-tailscale-and-close-port-22-1a52</guid>
      <description>&lt;p&gt;Once a VPS is hardened the usual way, keys only, firewalled, patched, there is a bigger move you can make: stop exposing SSH to the public internet at all. Instead of trusting that a strong key holds up against constant scanning, you put SSH on a private network the rest of the world cannot even see, and close port 22 to everyone else. This is my favorite upgrade for a small server in 2026, and it is genuinely less fragile than it sounds, as long as you keep one escape hatch.&lt;/p&gt;

&lt;p&gt;This guide picks up where &lt;a href="https://www.techdevmantra.com/guides/secure-vps-initial-setup" rel="noopener noreferrer"&gt;Setting Up Your Own VPS&lt;/a&gt; leaves off. If you have not done the baseline (non-root user, SSH keys, UFW), start there first.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tailscale gives your server a private address that only your own devices can reach.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disable key expiry on the server in the admin console, or Tailscale logs you out in 180 days and you cannot reauth remotely.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Allow the &lt;code&gt;tailscale0&lt;/code&gt; interface in UFW, then remove the public port 22 rule.&lt;/li&gt;
&lt;li&gt;Always test a new connection over Tailscale before you close the old one.&lt;/li&gt;
&lt;li&gt;Keep your provider's browser console handy as a recovery path.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What "behind Tailscale" actually means
&lt;/h2&gt;

&lt;p&gt;Tailscale builds a private mesh network (a "tailnet") between your machines using WireGuard. Every device you add gets a stable &lt;code&gt;100.x&lt;/code&gt; address that is reachable only by your other devices, never from the open internet. Put your server and your laptop on the same tailnet and you can SSH to the server over that private address. Once that works, public port 22 has no reason to exist, so you close it.&lt;/p&gt;

&lt;p&gt;There are two ways to run SSH over the tailnet, and it is worth knowing which you are choosing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plain OpenSSH over Tailscale.&lt;/strong&gt; You keep using normal OpenSSH and simply reach it through the private address. Your existing keys and hardening still apply. This is what I recommend for most people, because nothing about your battle-tested SSH setup changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale SSH.&lt;/strong&gt; Tailscale's own daemon answers on port 22 of the tailnet address and authenticates with your Tailscale identity and access rules. It is convenient, especially for teams, but it hands authentication to Tailscale instead of OpenSSH. Reasonable people disagree here; pick based on how much you want to lean on one provider.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either way, the firewall move is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install Tailscale and connect
&lt;/h2&gt;

&lt;p&gt;On the server, install Tailscale and bring it up. The install script supports every mainstream distro:&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;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command prints a URL. Open it, sign in, and the server joins your tailnet. Install Tailscale on your laptop the same way and sign in with the same account. Check the server's private address with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale ip &lt;span class="nt"&gt;-4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will get a &lt;code&gt;100.x.x.x&lt;/code&gt; address. From your laptop, confirm plain SSH works over it before changing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy@100.x.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather use Tailscale SSH, enable it without dropping your session:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Disable key expiry (do not skip this)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;This is the step people forget, and the one that locks them out. By default Tailscale expires a machine's key after 180 days and asks it to log in again. On your laptop that is a minor prompt, but on a headless server whose only door is Tailscale, it is a lockout you cannot fix remotely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the Tailscale admin console, open &lt;strong&gt;Machines&lt;/strong&gt;, find the server, open the "..." menu, and choose &lt;strong&gt;Disable key expiry&lt;/strong&gt;. Do this now, while you are thinking about it, for every server you put behind the tailnet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Let the tailnet through UFW, then close port 22
&lt;/h2&gt;

&lt;p&gt;Now tell the firewall to trust the Tailscale interface and remove the public SSH rule. This order matters. Following Tailscale's own &lt;a href="https://tailscale.com/docs/how-to/secure-ubuntu-server-with-ufw" rel="noopener noreferrer"&gt;ufw lockdown 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="c"&gt;# Allow everything arriving over the private Tailscale interface&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on tailscale0

&lt;span class="c"&gt;# Keep the public web ports if you serve a site; otherwise skip this&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80,443/tcp

&lt;span class="c"&gt;# Remove the public SSH opening&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw delete allow OpenSSH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for a leftover blanket rule. If &lt;code&gt;sudo ufw status&lt;/code&gt; still lists something like &lt;code&gt;22/tcp ALLOW IN Anywhere&lt;/code&gt;, delete it too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw delete allow 22/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the rules 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;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose
&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; :22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ufw status verbose&lt;/code&gt; should show &lt;code&gt;Default: deny (incoming)&lt;/code&gt;, an &lt;code&gt;ALLOW ... on tailscale0&lt;/code&gt; line, and no rule for port 22. One point that trips people up: &lt;code&gt;sshd&lt;/code&gt; keeps listening on &lt;code&gt;0.0.0.0:22&lt;/code&gt;, so &lt;code&gt;ss&lt;/code&gt; still shows it there, and that is fine. With this approach UFW is what blocks the public side, not &lt;code&gt;sshd&lt;/code&gt;, so you verify by behavior (the next step) rather than by the listen address. If you would rather &lt;code&gt;sshd&lt;/code&gt; not listen on the public interface at all, you can add &lt;code&gt;ListenAddress&lt;/code&gt; lines for your Tailscale and loopback addresses to the SSH drop-in, but leave that as optional hardening: if the tailnet interface is not up when &lt;code&gt;sshd&lt;/code&gt; starts, binding fails, so the firewall rule is the safer primary control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Test before you trust it
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Never close your working session until a fresh one succeeds. Keep your current SSH terminal open until you have confirmed a new connection works over the tailnet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With your current SSH terminal still open, start a new terminal and connect over the tailnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy@100.x.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that lands you on the server, public SSH is closed and private SSH works. Now you can close the old session. If it fails, you still have the original terminal to undo the firewall change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your safety net if it all goes wrong
&lt;/h2&gt;

&lt;p&gt;Two things keep this from ever becoming a real lockout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The provider console.&lt;/strong&gt; Hostinger and most hosts include a browser based terminal in their control panel that reaches the server directly, not over SSH. If Tailscale is ever down or misconfigured, that is how you get in to fix it. This is the recovery path you were told to find in the baseline guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reversal order.&lt;/strong&gt; If you ever remove Tailscale from the server, re-open public SSH first (&lt;code&gt;sudo ufw allow OpenSSH&lt;/code&gt;), or you will delete your only way in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is this actually a good idea? The trade-offs
&lt;/h2&gt;

&lt;p&gt;I like this setup, but it is fair to name what you are trading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are trusting a third party.&lt;/strong&gt; Tailscale and its coordination servers become part of your access path. Some people would rather depend only on OpenSSH, which is open source and among the most audited software in the world. That is a legitimate position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can own the control plane.&lt;/strong&gt; If that worries you, run &lt;a href="https://github.com/juanfont/headscale" rel="noopener noreferrer"&gt;Headscale&lt;/a&gt;, an open source implementation of the Tailscale control server, or use &lt;a href="https://netbird.io/" rel="noopener noreferrer"&gt;NetBird&lt;/a&gt; as an alternative mesh. More work, less reliance on one company.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is remote access, not everything.&lt;/strong&gt; Public web traffic still needs a plan. You can keep serving 80 and 443 directly, or, to avoid opening even those, put the app behind a tunnel. That is a good topic for its own guide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you self-host a handful of services for yourself and value not watching your SSH port get scanned a thousand times a day, this is hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start here if you skipped it:&lt;/strong&gt; &lt;a href="https://www.techdevmantra.com/guides/secure-vps-initial-setup" rel="noopener noreferrer"&gt;Setting Up Your Own VPS: A Secure Starting Point&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put something behind the tailnet:&lt;/strong&gt; admin panels are perfect candidates. In the &lt;a href="https://www.techdevmantra.com/guides/self-host-n8n-vps-docker-postgresql-2026-production-guide" rel="noopener noreferrer"&gt;n8n guide&lt;/a&gt; you could keep the editor private on the tailnet and only expose webhooks publicly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The mental shift is simple: your server should not answer the door for strangers at all. Give it a private address, let only your own machines knock, and keep one emergency key (the provider console) for the bad day. Do that, and the daily reality of running a public VPS gets a lot quieter.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Verified end to end on a real Ubuntu 24.04.4 server: joined a tailnet, allowed &lt;code&gt;tailscale0&lt;/code&gt; in UFW, and removed the public port 22 rule. From a separate machine, SSH to the public IP then timed out while SSH over the tailnet address still logged in, and the kernel firewall log showed the public SYN dropped on &lt;code&gt;eth0&lt;/code&gt;. The reversal step re-opened public SSH cleanly. See the linked lab notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>tailscale</category>
      <category>security</category>
      <category>linux</category>
    </item>
    <item>
      <title>Setting Up Your Own VPS: A Secure Starting Point</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:39:20 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/setting-up-your-own-vps-a-secure-starting-point-2990</link>
      <guid>https://dev.to/shubham_sharma_94/setting-up-your-own-vps-a-secure-starting-point-2990</guid>
      <description>&lt;p&gt;Every self-hosted project I run starts the same way: a brand new VPS and about twenty minutes of setup before I install a single application. That twenty minutes is what separates "my server" from "someone else's crypto miner." A fresh box with a public IP starts getting probed within minutes, and the default configuration on most images is built for convenience, not safety.&lt;/p&gt;

&lt;p&gt;This is the secure baseline I set up on every new server, before Docker, before n8n, before anything else. It is also the starting point our &lt;a href="https://www.techdevmantra.com/guides/self-host-n8n-vps-docker-postgresql-2026-production-guide" rel="noopener noreferrer"&gt;production n8n guide&lt;/a&gt; assumes you already have. Every command below was checked against current Ubuntu LTS documentation, and I flag the parts that genuinely need a real server to verify.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never do daily work as root. Create a sudo user and log in as that instead.&lt;/li&gt;
&lt;li&gt;Use an SSH key and turn password login off, but only after you confirm the key works.&lt;/li&gt;
&lt;li&gt;Deny everything at the firewall by default, then open only the ports you actually use.&lt;/li&gt;
&lt;li&gt;Turn on automatic security updates so patches land while you sleep.&lt;/li&gt;
&lt;li&gt;If you plan to run Docker, remember that published ports skip UFW. Bind them to &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS running a current Ubuntu LTS. Both 24.04 "Noble Numbat" and 26.04 "Resolute Raccoon" work well. I run long-lived boxes on &lt;a href="https://www.hostinger.com/in?REFERRALCODE=TECHDEVMANTRA" rel="noopener noreferrer"&gt;Hostinger VPS hosting&lt;/a&gt;, which is also what powers the n8n guide.&lt;/li&gt;
&lt;li&gt;An SSH key pair on your own machine. If you do not have one yet, Step 3 creates it.&lt;/li&gt;
&lt;li&gt;A terminal, and a note of your provider's recovery console. Most hosts, Hostinger included, give you a browser based console in their control panel. That is your way back in if you ever lock yourself out, so find it before you start.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; some links in this guide, including the Hostinger link above, are referral or affiliate links. If you sign up through them we may earn account credit or a commission, at no extra cost to you. We only point at tools we actually run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1: Log in and update the system
&lt;/h2&gt;

&lt;p&gt;Right after the server boots, log in with the credentials your provider gave you and bring every package up to date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@YOUR_SERVER_IP
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the upgrade pulls a new kernel, reboot with &lt;code&gt;reboot&lt;/code&gt; and log back in. Starting from a fully patched system means the rest of this guide is the only thing left between you and a solid baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a non-root user
&lt;/h2&gt;

&lt;p&gt;Working as root all day is the single most common mistake on a new server. One typo or one bad script runs with full control of the machine. Create a normal user with sudo rights and use that from now on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adduser deploy
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;deploy&lt;/code&gt; for whatever name you like. The &lt;code&gt;adduser&lt;/code&gt; command asks for a password; pick a strong one, since sudo will ask for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Set up SSH keys
&lt;/h2&gt;

&lt;p&gt;From your own machine, not the server, create a key if you do not already have one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"you@your-machine"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the public half up to the new user, then log in as that user to confirm it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519.pub deploy@YOUR_SERVER_IP
ssh deploy@YOUR_SERVER_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not move on until that last command logs you in without asking for the account password. The next step turns password login off completely, and if the key is not working you will lock yourself out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Harden SSH
&lt;/h2&gt;

&lt;p&gt;Now lock SSH down to keys only and stop root from logging in over it. On Ubuntu, the clean way is a drop-in file, so a future package update cannot quietly overwrite your changes. As the &lt;code&gt;deploy&lt;/code&gt; user:&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 tee&lt;/span&gt; /etc/ssh/sshd_config.d/99-hardening.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &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;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one catch that trips people up. SSH reads its settings top to bottom and keeps the first value it finds for each one, and cloud images ship a lower numbered file that sets &lt;code&gt;PasswordAuthentication&lt;/code&gt; for you. On Ubuntu 24.04 it is &lt;code&gt;60-cloudimg-settings.conf&lt;/code&gt;, which already sets it to &lt;code&gt;no&lt;/code&gt;; older images used &lt;code&gt;50-cloud-init.conf&lt;/code&gt; and sometimes set it to &lt;code&gt;yes&lt;/code&gt;. Because a lower numbered file loads first, whatever it says wins over your file. So do not trust the filename alone. Check the effective configuration, which is the real source of truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt;   &lt;span class="c"&gt;# tests syntax; no output means it is fine&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"permitrootlogin|passwordauthentication|pubkeyauthentication"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see &lt;code&gt;passwordauthentication no&lt;/code&gt; and &lt;code&gt;permitrootlogin no&lt;/code&gt; in that output. If password auth still says &lt;code&gt;yes&lt;/code&gt;, open the offending lower numbered file, comment that line out, and check again. Once it reads correctly, reload SSH (this does not drop your current session):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep your existing terminal open and log in from a second one to be sure. If anything is wrong, the open session is your safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Turn on a firewall
&lt;/h2&gt;

&lt;p&gt;UFW is a friendly front end to the kernel firewall. It is present on the desktop and full server images, but a minimal cloud image often does not include it, so install it first (a no-op if it is already there). Then set it to deny everything coming in, allow your own traffic out, and open only SSH and the web ports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; ufw
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80,443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable
sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are not serving a website yet, skip the &lt;code&gt;80,443&lt;/code&gt; line and add it later. The whole idea is that nothing is reachable unless you said so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Automatic security updates
&lt;/h2&gt;

&lt;p&gt;Security patches are only useful once they are installed. The &lt;code&gt;unattended-upgrades&lt;/code&gt; package applies them for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;unattended-upgrades &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure &lt;span class="nt"&gt;--priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;low unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose "Yes" at the prompt. You can preview what it would do without changing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;unattended-upgrades &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;--debug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default it installs security updates only, which is the sweet spot: you stay patched without surprise changes to everything else on the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: (Optional) Add fail2ban
&lt;/h2&gt;

&lt;p&gt;With password login already off, brute force attempts against SSH are mostly noise, since there is no password to guess. If you still want to trim the log spam, &lt;code&gt;fail2ban&lt;/code&gt; watches for repeated failures and bans the source for a while:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its defaults enable an SSH jail out of the box. Treat this as a nicety, not a substitute for keys and a firewall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker trap almost everyone hits
&lt;/h2&gt;

&lt;p&gt;Here is the one that surprises even experienced people, and it is why it belongs in the baseline.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Docker bypasses UFW.&lt;/strong&gt; When you publish a container port, Docker writes its own firewall rules that skip UFW entirely. Your &lt;code&gt;ufw status&lt;/code&gt; can look locked down while a database sits wide open to the internet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reason is where each tool sits in the network path. Docker sends published-port traffic through its own chain before it ever reaches the point UFW inspects, so UFW never gets a say. This is documented behavior, described in Docker's own &lt;a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" rel="noopener noreferrer"&gt;packet filtering and firewalls&lt;/a&gt; page and flagged by the OWASP Docker Security Cheat Sheet.&lt;/p&gt;

&lt;p&gt;The simplest, most reliable fix is to bind published ports to localhost instead of every interface. Compare:&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;# Exposed to the whole internet, even with UFW "on":&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;5432:5432"&lt;/span&gt;

&lt;span class="c1"&gt;# Reachable only from the server itself:&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:5432:5432"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still, do not publish internal services at all. Containers on the same Docker network reach each other by name, so a database that only your app talks to needs no host port. That is exactly the pattern in our &lt;a href="https://www.techdevmantra.com/guides/self-host-n8n-vps-docker-postgresql-2026-production-guide" rel="noopener noreferrer"&gt;n8n guide&lt;/a&gt;, where Postgres is never published and only Caddy faces the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;You now have a server that is patched, key-only, firewalled, and no longer running as root. Two natural next steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Take SSH off the public internet entirely.&lt;/strong&gt; In the next guide I put SSH behind a private Tailscale network and close port 22 to the world: &lt;a href="https://www.techdevmantra.com/guides/ssh-behind-tailscale-close-port-22" rel="noopener noreferrer"&gt;Put SSH behind Tailscale and close port 22&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run something on it.&lt;/strong&gt; Our &lt;a href="https://www.techdevmantra.com/guides/self-host-n8n-vps-docker-postgresql-2026-production-guide" rel="noopener noreferrer"&gt;production n8n guide&lt;/a&gt; uses this exact baseline as its foundation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  An honest word on trade-offs
&lt;/h2&gt;

&lt;p&gt;None of this makes a server "unhackable," and anyone who tells you a checklist does is selling something. What it does is remove the easy wins: default passwords, root over SSH, exposed services, unpatched holes. That covers the overwhelming majority of automated attacks, which is what actually hits a small VPS. Keep your software updated, keep backups you have tested, and add depth (like the Tailscale step) as your setup grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The shape of this never really changes: a real user, keys not passwords, a default-deny firewall, automatic patches, and an awareness of how Docker treats ports. Do it once, turn it into muscle memory, and every future box takes ten minutes. Then you get to the fun part, which is running your own software.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Verified on a real Ubuntu 24.04.4 server with Docker 29.1.3: the SSH hardening effective values (&lt;code&gt;sshd -T&lt;/code&gt;), the default-deny UFW policy, the Docker-bypasses-UFW behavior and the &lt;code&gt;127.0.0.1&lt;/code&gt; fix (an external request reached the &lt;code&gt;0.0.0.0&lt;/code&gt;-published port straight through an active firewall, then was refused once the port was bound to loopback), automatic security updates, and the fail2ban SSH jail. See the linked lab notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Self-Host n8n on a VPS with Docker, PostgreSQL, and Caddy (2026)</title>
      <dc:creator>Shubham Sharma</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:39:17 +0000</pubDate>
      <link>https://dev.to/shubham_sharma_94/self-host-n8n-on-a-vps-with-docker-postgresql-and-caddy-2026-4ff</link>
      <guid>https://dev.to/shubham_sharma_94/self-host-n8n-on-a-vps-with-docker-postgresql-and-caddy-2026-4ff</guid>
      <description>&lt;p&gt;I stood this exact stack up before writing a line of it: n8n 2.36.8 talking to PostgreSQL 16.13, fronted by Caddy 2.11.4 for automatic HTTPS. Every command and version below comes from that run. If you have only used the one-line &lt;code&gt;docker run&lt;/code&gt; for n8n, this is the production version: a real database instead of the default SQLite, HTTPS without hand-managing certificates, and a layout you can back up and upgrade without losing your workflows.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use PostgreSQL, not the default SQLite: set &lt;code&gt;DB_TYPE=postgresdb&lt;/code&gt; and the &lt;code&gt;DB_POSTGRESDB_*&lt;/code&gt; variables.&lt;/li&gt;
&lt;li&gt;Set a persistent &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; before first launch, or you lock yourself out of saved credentials on the next redeploy.&lt;/li&gt;
&lt;li&gt;Let Caddy own HTTPS: point your domain at the server and it provisions a Let's Encrypt certificate automatically.&lt;/li&gt;
&lt;li&gt;Verified on n8n 2.36.8, PostgreSQL 16.13, Caddy 2.11.4.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS (1 vCPU and 2 GB RAM is enough to start) on a recent Linux, with a public IP. New to running a server? Our &lt;a href="https://www.techdevmantra.com/guides/secure-vps-initial-setup" rel="noopener noreferrer"&gt;secure VPS setup guide&lt;/a&gt; gets you to a safe baseline first.&lt;/li&gt;
&lt;li&gt;A domain or subdomain you control: an A record for &lt;code&gt;n8n.example.com&lt;/code&gt; pointing at the VPS.&lt;/li&gt;
&lt;li&gt;Docker Engine and the Compose plugin installed. New to Docker? Our &lt;a href="https://www.techdevmantra.com/guides/run-local-llms-ramalama-docker-mac" rel="noopener noreferrer"&gt;RamaLama and Docker walkthrough&lt;/a&gt; covers the basics on your own machine first.&lt;/li&gt;
&lt;li&gt;Ports 80 and 443 open to the internet (Caddy needs them for HTTPS). Keep 5678 closed.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; &lt;code&gt;n8n.example.com&lt;/code&gt; is a placeholder. Replace it everywhere below (in &lt;code&gt;.env&lt;/code&gt;, the &lt;code&gt;Caddyfile&lt;/code&gt;, and your DNS record) with a subdomain you actually own, for example &lt;code&gt;n8n.yourdomain.com&lt;/code&gt;. &lt;code&gt;example.com&lt;/code&gt; is a reserved documentation domain, so it will never issue a TLS certificate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1: Create the project and secrets
&lt;/h2&gt;

&lt;p&gt;SSH into the VPS, make a directory, and generate two secrets: a database password and n8n's encryption key. The encryption key is the one people forget. n8n uses it to encrypt saved credentials, so if it changes between deploys, every stored credential becomes unreadable.&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; ~/n8n &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/n8n
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&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; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"N8N_ENCRYPTION_KEY=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"N8N_HOST=n8n.example.com"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;n8n.example.com&lt;/code&gt; for your domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Write the Compose file
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;docker-compose.yml&lt;/code&gt; with three services: Postgres (n8n's database), n8n, and Caddy as the HTTPS reverse proxy. Note that n8n's port 5678 is not published to the host; Caddy reaches it over the internal network, so it never faces the internet directly. Every n8n setting used below is documented in &lt;a href="https://docs.n8n.io/hosting/" rel="noopener noreferrer"&gt;n8n's hosting docs&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;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-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_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n&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;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n&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/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;n8n&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;n8n"&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;12&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;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;n8nio/n8n:2.36.8&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_TYPE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresdb&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n&lt;/span&gt;
      &lt;span class="na"&gt;DB_POSTGRESDB_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;N8N_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${N8N_HOST}&lt;/span&gt;
      &lt;span class="na"&gt;N8N_PROTOCOL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="na"&gt;N8N_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5678&lt;/span&gt;
      &lt;span class="na"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://${N8N_HOST}/&lt;/span&gt;
      &lt;span class="na"&gt;N8N_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${N8N_ENCRYPTION_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;N8N_RUNNERS_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;GENERIC_TIMEZONE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UTC&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;n8ndata:/home/node/.n8n&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;caddy&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;caddy:2-alpine&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="nv"&gt;n8n&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="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddydata:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddyconfig:/config&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;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;n8ndata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddydata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddyconfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pinned n8n to &lt;code&gt;2.36.8&lt;/code&gt;, the version I tested, so your deploy matches this guide. Bump it deliberately later rather than tracking &lt;code&gt;latest&lt;/code&gt; blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Write the Caddyfile
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;n8n.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;n8n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5678&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire HTTPS setup. Once your domain resolves to the server and 80 and 443 are reachable, Caddy requests a Let's Encrypt certificate on the first visit and renews it on its own. No certbot, no cron job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Launch and verify
&lt;/h2&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;Compose starts Postgres first, waits for its healthcheck to pass, then starts n8n, which connects and runs its database migrations. Confirm both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; n8n   &lt;span class="c"&gt;# watch the migrations finish, then Ctrl-C&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my run, n8n 2.36.8 came up healthy in about ten seconds once the images were cached, and the log showed it running its migration set against Postgres 16.13. That is exactly what you want to see: n8n is using Postgres, not silently falling back to SQLite. If you temporarily add &lt;code&gt;ports: ["127.0.0.1:5678:5678"]&lt;/code&gt; to the n8n service, &lt;code&gt;curl http://localhost:5678/healthz&lt;/code&gt; returns &lt;code&gt;{"status":"ok"}&lt;/code&gt;. Remove it once Caddy is serving.&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;https://n8n.example.com&lt;/code&gt; and n8n prompts you to create the owner account. Do that immediately, before anyone else finds the URL.&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%2Fao3u1mekuqu4b0t2p233.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%2Fao3u1mekuqu4b0t2p233.webp" alt="The n8n owner-account setup screen on first launch" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once that is done you land on the workflow editor, ready to build your first automation:&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%2Fucclfos9af30q3k2kr70.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%2Fucclfos9af30q3k2kr70.webp" alt="The n8n workflow editor with the Add first step prompt" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Harden it
&lt;/h2&gt;

&lt;p&gt;A few settings separate a demo from something you leave running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encryption key:&lt;/strong&gt; you set &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; in Step 1. Keep &lt;code&gt;.env&lt;/code&gt; backed up somewhere safe; losing it means re-entering every credential by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall:&lt;/strong&gt; allow only SSH, 80, and 443. On Ubuntu: &lt;code&gt;ufw allow OpenSSH &amp;amp;&amp;amp; ufw allow 80,443/tcp &amp;amp;&amp;amp; ufw enable&lt;/code&gt;. Never expose 5678.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart policy:&lt;/strong&gt; &lt;code&gt;restart: unless-stopped&lt;/code&gt; (already in the file) brings the stack back after a reboot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stay patched:&lt;/strong&gt; keep the host updated, and treat n8n version bumps as a deliberate step.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Back up and upgrade
&lt;/h2&gt;

&lt;p&gt;Two volumes hold your state: &lt;code&gt;pgdata&lt;/code&gt; (workflows, executions, credentials) and &lt;code&gt;n8ndata&lt;/code&gt; (n8n's config). Back up both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# database dump&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; n8n n8n | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; n8n-db-&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;.sql.gz
&lt;span class="c"&gt;# n8n data volume&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; n8n_n8ndata:/data &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;"&lt;/span&gt;:/backup alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/n8n-data-&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;.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;p&gt;To upgrade, change the pinned tag in &lt;code&gt;docker-compose.yml&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;docker compose pull n8n
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; n8n
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;n8n runs any new migrations on start. Because your data lives in the Postgres and &lt;code&gt;n8ndata&lt;/code&gt; volumes, the container itself is disposable, which is the entire point of running it this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common issues
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credentials read as broken after a redeploy.&lt;/strong&gt; The &lt;code&gt;N8N_ENCRYPTION_KEY&lt;/code&gt; changed. Restore the original key from your &lt;code&gt;.env&lt;/code&gt; backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The certificate never issues.&lt;/strong&gt; Caddy needs the domain's A record pointing at the server and ports 80 and 443 reachable. Check &lt;code&gt;docker compose logs caddy&lt;/code&gt; for ACME errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n warns about task runners.&lt;/strong&gt; n8n 2.x expects &lt;code&gt;N8N_RUNNERS_ENABLED=true&lt;/code&gt;, which is already set above.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need PostgreSQL, or is SQLite fine?&lt;/strong&gt; SQLite works for a hobby instance, but for anything you rely on, Postgres handles concurrent executions and backups far better. Switching later is a migration; starting on Postgres avoids it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Nginx instead of Caddy?&lt;/strong&gt; Yes, but Caddy's automatic HTTPS is the reason it is here: one line of config versus managing certbot and renewals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much server do I need?&lt;/strong&gt; A 1 vCPU and 2 GB VPS runs a light instance. Heavy or highly concurrent workflows want more RAM and, eventually, n8n's queue mode with separate worker containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The shape here, an application plus Postgres plus Caddy in one Compose file, is the same one you will reuse for most self-hosted tools. Set the encryption key, let Caddy own TLS, keep your state in named volumes, and upgrades become a two-line routine.&lt;/p&gt;

&lt;p&gt;Verified end to end on n8n 2.36.8, PostgreSQL 16.13, and Caddy 2.11.4.&lt;/p&gt;

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