<?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>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"&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"&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>
