<?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: Edwin Sanjo Soji</title>
    <description>The latest articles on DEV Community by Edwin Sanjo Soji (@edwinnn).</description>
    <link>https://dev.to/edwinnn</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%2F4026348%2F7296192c-6ec7-48e1-a069-24e9f424ba72.jpg</url>
      <title>DEV Community: Edwin Sanjo Soji</title>
      <link>https://dev.to/edwinnn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edwinnn"/>
    <language>en</language>
    <item>
      <title>Mastering Edge Routing: The Definitive Caddy Reverse Proxy Guide</title>
      <dc:creator>Edwin Sanjo Soji</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:59:40 +0000</pubDate>
      <link>https://dev.to/edwinnn/mastering-edge-routing-the-definitive-caddy-reverse-proxy-guide-3nbb</link>
      <guid>https://dev.to/edwinnn/mastering-edge-routing-the-definitive-caddy-reverse-proxy-guide-3nbb</guid>
      <description>&lt;p&gt;When exposing web applications, Docker containers, or internal services to the public internet, you need a reverse proxy. While Nginx is the legacy powerhouse, Caddy has become the modern standard due to its built-in, fully automated HTTPS and incredibly readable configuration files.&lt;/p&gt;

&lt;p&gt;This guide will walk you through a proper installation, a basic setup that is more than enough for most solo developers, and an advanced modular setup designed for multi-user server environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: The Proper Installation
&lt;/h2&gt;

&lt;p&gt;Do not use the default Ubuntu package manager (&lt;code&gt;apt&lt;/code&gt;) to install Caddy right away. The default repositories often host significantly outdated versions. Instead, we will add Caddy's official Cloudsmith repository to ensure we get the latest stable release with current security patches.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Install prerequisite tools:
&lt;/h3&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 update
&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; debian-keyring debian-archive-keyring apt-transport-https curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Download and add the official Caddy GPG keys:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'&lt;/span&gt; | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/caddy-stable-archive-keyring.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Add the Caddy repository to your system:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/caddy-stable.list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Install Caddy:
&lt;/h3&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 update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Phase 2: The Basic Setup (Enough for Most Users)
&lt;/h2&gt;

&lt;p&gt;If you are running a single server with just one or two applications (like a personal portfolio and a single backend API), this basic setup is completely sufficient. You do not need to overcomplicate your architecture.&lt;/p&gt;

&lt;p&gt;Caddy's entire routing logic lives in a single file called the &lt;code&gt;Caddyfile&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Open the Caddyfile for editing:
&lt;/h3&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;nano /etc/caddy/Caddyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Replace the default text with your routing blocks:
&lt;/h3&gt;

&lt;p&gt;Delete whatever is in there and write your site configuration. Here is an example of routing a domain to a Next.js app running locally on port 3000:&lt;br&gt;
&lt;/p&gt;

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

api.yourdomain.com {
    reverse_proxy 127.0.0.1:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Save the file and reload Caddy:
&lt;/h3&gt;

&lt;p&gt;Press &lt;code&gt;CTRL + O&lt;/code&gt;, then &lt;code&gt;Enter&lt;/code&gt; to save, and &lt;code&gt;CTRL + X&lt;/code&gt; to exit Nano. Then apply the changes:&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 caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy will instantly request SSL certificates for your domains and securely route your traffic. &lt;strong&gt;If you are a solo developer with a simple setup, stop here. You are done.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 3: The Modular &lt;code&gt;conf.d&lt;/code&gt; Setup
&lt;/h2&gt;

&lt;p&gt;If you are hosting dozens of services, running a complex homelab, or collaborating with a team, cramming everything into one massive &lt;code&gt;Caddyfile&lt;/code&gt; becomes messy and dangerous. A single typo could take down every site on the server.&lt;/p&gt;

&lt;p&gt;Instead, we split our routes into a modular directory architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Create a directory for individual configurations:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/caddy/conf.d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Edit the main Caddyfile to act as an importer:
&lt;/h3&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;nano /etc/caddy/Caddyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the contents with this exact structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    # Global administrative settings can go here
    # email admin@yourdomain.com
}

# Import all individual site configurations
import conf.d/*.caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save and exit. &lt;/p&gt;

&lt;h3&gt;
  
  
  3. Create dedicated files for each service:
&lt;/h3&gt;

&lt;p&gt;Now, whenever you add a new app, give it its own file. For example, to add &lt;code&gt;code-server&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;nano /etc/caddy/conf.d/code-server.caddy
&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;code.yourdomain.com {
    reverse_proxy 127.0.0.1:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload Caddy (&lt;code&gt;sudo systemctl reload caddy&lt;/code&gt;). If you ever make a mistake in &lt;code&gt;code-server.caddy&lt;/code&gt;, it won't break your other applications.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 4: Scoped User Permissions (Zero-Downtime Reloads)
&lt;/h2&gt;

&lt;p&gt;In a multi-user environment, you might have standard user accounts (like a &lt;code&gt;CodexSandboxUsers&lt;/code&gt; group or a secondary developer account) that frequently deploy new apps. They need the ability to reload Caddy to apply their new &lt;code&gt;.caddy&lt;/code&gt; files, but giving them full &lt;code&gt;sudo&lt;/code&gt; access to the server is a massive security risk.&lt;/p&gt;

&lt;p&gt;We can grant a specific user or group the exact permission to reload Caddy—and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Create a dedicated sudoers rule:
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;[!info] Why visudo?&lt;br&gt;
We use &lt;code&gt;visudo&lt;/code&gt; instead of &lt;code&gt;nano&lt;/code&gt; here because if you make a syntax error in a sudoers file, you can permanently lock yourself out of administrative access. &lt;code&gt;visudo&lt;/code&gt; checks your work before saving.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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;visudo &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/sudoers.d/caddy-reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Add the scoped privilege rule:
&lt;/h3&gt;

&lt;p&gt;Add this line to allow the standard &lt;code&gt;ubuntu&lt;/code&gt; user to reload Caddy without a password (adjust the username as needed for your specific setup):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ubuntu ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save and exit (&lt;code&gt;CTRL + O&lt;/code&gt;, &lt;code&gt;Enter&lt;/code&gt;, &lt;code&gt;CTRL + X&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Now, when your standard user deploys a new application, they can simply type &lt;code&gt;sudo systemctl reload caddy&lt;/code&gt;. It will execute flawlessly without prompting for a password, and without giving them the keys to your entire infrastructure.&lt;/p&gt;

</description>
      <category>caddy</category>
      <category>reverseproxy</category>
    </item>
    <item>
      <title>Zero to Production: Securing and Provisioning a Fresh Linux Server</title>
      <dc:creator>Edwin Sanjo Soji</dc:creator>
      <pubDate>Sat, 07 Nov 2026 13:39:29 +0000</pubDate>
      <link>https://dev.to/edwinnn/zero-to-production-securing-and-provisioning-a-fresh-linux-server-4c06</link>
      <guid>https://dev.to/edwinnn/zero-to-production-securing-and-provisioning-a-fresh-linux-server-4c06</guid>
      <description>&lt;p&gt;Deploying a self-hosted environment on a fresh Linux instance requires establishing a secure baseline before you start spinning up web services or databases. A naked Ubuntu server exposed to the public internet is vulnerable to automated scanning and brute-force attacks within minutes of deployment.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Read, Understand, and Apply Selectively&lt;/strong&gt;&lt;br&gt;
Please read this entire guide carefully before executing commands. Take the time to understand what each phase does, and only apply the configurations required for your specific architecture. Some steps (like strict cloud-provider rules or swap memory) may not be necessary for every environment. If a step doesn't fit your use case, skip it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This guide establishes a chronological, production-ready DevSecOps baseline from the moment you first SSH into your new virtual machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Initial System Provisioning
&lt;/h2&gt;

&lt;p&gt;Before configuring any services, you must ensure the base operating system is up-to-date and accurately tracking time. A fresh instance will likely have pending security patches.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1 System Update and Upgrade
&lt;/h3&gt;

&lt;p&gt;Run a full system update and upgrade:&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 update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Kernel Updates&lt;/strong&gt;&lt;br&gt;
Depending on the kernel updates, you may be prompted to restart certain services. It is generally safe to select the default options and press Enter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1.2 Set System Timezone
&lt;/h3&gt;

&lt;p&gt;Accurate system time is critical for security tools (like Fail2ban) to read log timestamps properly. Set your server to your local timezone (e.g., UTC or Asia/Kolkata):&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;timedatectl set-timezone UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Phase 2: Core Security &amp;amp; Firewall Setup
&lt;/h2&gt;

&lt;p&gt;Relying on raw &lt;code&gt;iptables&lt;/code&gt; directly is dangerous—flushing them can result in an immediate SSH lockout. UFW (Uncomplicated Firewall) provides a safe abstraction layer, while Fail2ban prevents brute-force attacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Install &amp;amp; Automate Security Packages
&lt;/h3&gt;

&lt;p&gt;First, install UFW, Fail2ban, and the automatic upgrade utility:&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;ufw fail2ban unattended-upgrades &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, configure the system to actually execute those upgrades automatically in the background. Open the configuration 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;sudo &lt;/span&gt;nano /etc/apt/apt.conf.d/20auto-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the following two lines into the file, then save and exit (&lt;code&gt;Ctrl+O&lt;/code&gt;, &lt;code&gt;Enter&lt;/code&gt;, &lt;code&gt;Ctrl+X&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;APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.2 UFW and Port Management
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Warning&lt;/p&gt;
&lt;p&gt;SSH Lockout&lt;br&gt;
You MUST explicitly allow OpenSSH before enabling UFW, or your active session will be immediately severed.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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 default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing

&lt;span class="c"&gt;# Infrastructure Allowances&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="c"&gt;# Add other necessary ports here later, e.g., sudo ufw allow 80,443/tcp for web servers&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;[!info] Optional: Strict Cloud Providers&lt;br&gt;
Some cloud providers (like Oracle Cloud) enforce underlying iptables rules that can block web traffic even if UFW allows it. If you plan to host web applications and encounter blockages, run this to explicitly force ports 80 and 443 open:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo iptables -I INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2.3 Fail2ban Integration
&lt;/h3&gt;

&lt;p&gt;Force Fail2ban to utilize UFW instead of raw &lt;code&gt;iptables&lt;/code&gt; to prevent firewall rule conflicts. Open a new local configuration 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;sudo &lt;/span&gt;nano /etc/fail2ban/jail.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the following configuration into the file, then save and exit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[DEFAULT]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;bantime&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="s"&gt;1h&lt;/span&gt;
&lt;span class="py"&gt;findtime&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="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;maxretry&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="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;banaction&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="s"&gt;ufw&lt;/span&gt;
&lt;span class="py"&gt;banaction_allports&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="s"&gt;ufw&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[sshd]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;enabled&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="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;backend&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="s"&gt;systemd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable and start the service:&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 &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.4 Disable SSH Password Authentication
&lt;/h3&gt;

&lt;p&gt;Once you have verified that you can log into the server securely using your SSH key, you must disable password authentication. This renders brute-force dictionary attacks mathematically impossible.&lt;/p&gt;

&lt;p&gt;Open the main SSH configuration 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;sudo &lt;/span&gt;nano /etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scroll through the file and look for these two settings. You may need to remove the &lt;code&gt;#&lt;/code&gt; symbol at the start of the line to uncomment them, and ensure both are set to &lt;code&gt;no&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;PasswordAuthentication no
PermitRootLogin no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save and exit the file. Then, restart the SSH service to apply the new rules:&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 restart ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Phase 3: System Stability (Swap Memory)
&lt;/h2&gt;

&lt;p&gt;Running memory-intensive applications can cause Out-Of-Memory (OOM) crashes on servers with limited RAM. Creating a dedicated swap file prevents catastrophic failures when your system memory maxes out by utilizing disk space as a safety net.&lt;/p&gt;

&lt;p&gt;Create and mount an 8GB swap file (adjust the size based on your storage capacity):&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;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 8G /swapfile
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>devsecops</category>
      <category>security</category>
      <category>docker</category>
      <category>linux</category>
    </item>
    <item>
      <title>How to Set Up PostgreSQL with Docker on Ubuntu</title>
      <dc:creator>Edwin Sanjo Soji</dc:creator>
      <pubDate>Thu, 16 Jul 2026 03:33:18 +0000</pubDate>
      <link>https://dev.to/edwinnn/how-to-set-up-postgresql-with-docker-on-ubuntu-1p90</link>
      <guid>https://dev.to/edwinnn/how-to-set-up-postgresql-with-docker-on-ubuntu-1p90</guid>
      <description>&lt;blockquote&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;This post walks through running PostgreSQL 18 on a fresh Ubuntu server using Docker&lt;br&gt;
Compose, with a named volume for data persistence, secrets in a &lt;code&gt;.env&lt;/code&gt; file, a&lt;br&gt;
healthcheck, and the port bound to localhost only (with an SSH tunnel for remote&lt;br&gt;
access). By the end you'll have a Postgres instance that survives container&lt;br&gt;
restarts, isn't exposed to the open internet, and is easy to back up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You need a Postgres instance on a server for an app, a side project, or a homelab&lt;br&gt;
setup. Docker Compose is the fastest way to get a reproducible setup — no manual&lt;br&gt;
&lt;code&gt;apt install postgresql&lt;/code&gt;, no fighting with system package versions, and the whole&lt;br&gt;
config lives in one file you can commit to git (minus secrets).&lt;/p&gt;
&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Ubuntu 22.04 or 24.04 LTS
- Docker Engine (installed via Docker's official apt repo)
- Docker Compose v2 (the `docker compose` plugin, not the old standalone docker-compose)
- PostgreSQL 18 (official image from Docker Hub)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Approach / Key Decisions
&lt;/h2&gt;

&lt;p&gt;A few choices worth calling out before the config, since they're the parts people&lt;br&gt;
usually get wrong on the first try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin the image tag&lt;/strong&gt; (&lt;code&gt;postgres:18.4&lt;/code&gt;), not &lt;code&gt;latest&lt;/code&gt; — reproducibility matters
more than convenience once you have real data in the volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named volume, not bind mount&lt;/strong&gt;, for the data directory — avoids permission
headaches between the container's internal user and the host filesystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in &lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt;, never hardcoded in &lt;code&gt;docker-compose.yml&lt;/code&gt; — so the compose
file itself is safe to commit to git.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind the port to &lt;code&gt;127.0.0.1&lt;/code&gt; only&lt;/strong&gt; — Postgres has no business listening on
&lt;code&gt;0.0.0.0&lt;/code&gt; on a public server. If you need remote access, use an SSH tunnel or a
VPN, not an open port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a healthcheck&lt;/strong&gt; — so anything depending on Postgres (an app container,
a CI job) can wait for "actually ready to accept queries," not just "container
started."&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Solution / Implementation
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Install Docker Engine + Compose plugin
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# refresh package index and install packages needed to add a new apt repo over HTTPS&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&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; ca-certificates curl gnupg

&lt;span class="c"&gt;# create the directory that will hold Docker's GPG key&lt;/span&gt;
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="c"&gt;# download Docker's official GPG key and make it readable by apt&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/ubuntu/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;a+r /etc/apt/keyrings/docker.asc

&lt;span class="c"&gt;# add Docker's apt repo, matching your CPU arch and Ubuntu codename automatically&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"deb [arch=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; stable"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="c"&gt;# pick up the new repo, then install Docker Engine + the Compose v2 plugin&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&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; docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Let your user run docker without &lt;code&gt;sudo&lt;/code&gt; (log out/in, or &lt;code&gt;newgrp docker&lt;/code&gt;, after this):&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;# add your user to the docker group so you don't need sudo for every docker command&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;
&lt;span class="c"&gt;# refresh your shell's group membership without needing to fully log out&lt;/span&gt;
newgrp docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;         &lt;span class="c"&gt;# confirm Docker Engine installed correctly&lt;/span&gt;
docker compose version   &lt;span class="c"&gt;# confirm the Compose v2 plugin is available&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Set up the project directory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# init-scripts/ will hold optional setup SQL/shell scripts (explained below)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/postgres-docker/init-scripts
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/postgres-docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;init-scripts/&lt;/code&gt; is optional — anything in there (&lt;code&gt;.sql&lt;/code&gt; or &lt;code&gt;.sh&lt;/code&gt; files) runs once,&lt;br&gt;
automatically, the first time the container initializes an empty data directory.&lt;br&gt;
Useful for creating extra databases, extensions, or seed data.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Create the &lt;code&gt;.env&lt;/code&gt; file
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# writes a .env file that docker-compose.yml will read via ${VAR} substitution.&lt;/span&gt;
&lt;span class="c"&gt;# NOTE: docker compose's .env parser doesn't strip inline "# comments" after a&lt;/span&gt;
&lt;span class="c"&gt;# value, so don't add trailing comments on these lines — they'd become part of&lt;/span&gt;
&lt;span class="c"&gt;# the value itself. Generate a real password with: openssl rand -base64 24&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
POSTGRES_USER=appuser
POSTGRES_PASSWORD=change_this_to_a_long_random_password
POSTGRES_DB=appdb
POSTGRES_PORT=5432
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# keep secrets out of git&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  4. Create &lt;code&gt;docker-compose.yml&lt;/code&gt;
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18.4&lt;/span&gt;          &lt;span class="c1"&gt;# pinned version, not `latest` — see "Key Decisions" above&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres_db&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="c1"&gt;# auto-restart on crash or host reboot (as long as Docker itself starts on boot)&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# values are pulled from .env in the same directory via ${VAR} substitution&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;${POSTGRES_USER}&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;${POSTGRES_DB}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# bind to localhost only — NOT "5432:5432", which would expose it on all interfaces&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:${POSTGRES_PORT}:5432"&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="c1"&gt;# named volume — persists data across container recreation&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./init-scripts:/docker-entrypoint-initdb.d&lt;/span&gt;   &lt;span class="c1"&gt;# optional: runs once against an empty data dir&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# checks Postgres can actually accept queries, not just that the process is running&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_DB}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;   &lt;span class="c1"&gt;# how often to run the check&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="c1"&gt;# how long to wait for a response before counting it as failed&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;      &lt;span class="c1"&gt;# consecutive failures before marking the container "unhealthy"&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="c1"&gt;# declares the named volume used above; Docker manages the storage location&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  5. Bring it up
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;        &lt;span class="c"&gt;# start the container in the background, reading .env + docker-compose.yml&lt;/span&gt;
docker compose ps           &lt;span class="c"&gt;# check it's running (and, once healthchecks pass, "healthy")&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; postgres   &lt;span class="c"&gt;# tail the logs; Ctrl+C to stop watching (container keeps running)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Wait for &lt;code&gt;database system is ready to accept connections&lt;/code&gt; in the logs, or check&lt;br&gt;
the healthcheck status in &lt;code&gt;docker compose ps&lt;/code&gt; (it should show &lt;code&gt;healthy&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;From inside the container:&lt;/strong&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;# -it gives you an interactive terminal attached to the psql session&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; postgres_db psql &lt;span class="nt"&gt;-U&lt;/span&gt; appuser &lt;span class="nt"&gt;-d&lt;/span&gt; appdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once in &lt;code&gt;psql&lt;/code&gt;, sanity-check the connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;conninfo&lt;/span&gt;  &lt;span class="c1"&gt;-- shows which user/db/host/port you're connected as&lt;/span&gt;
&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;         &lt;span class="c1"&gt;-- lists all databases visible to this user&lt;/span&gt;
&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;         &lt;span class="c1"&gt;-- exit psql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Healthcheck directly:&lt;/strong&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;# same check docker-compose.yml's healthcheck runs internally — useful for manual debugging&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;postgres_db pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; appuser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From the host&lt;/strong&gt; (if you have &lt;code&gt;psql&lt;/code&gt; installed locally — &lt;code&gt;sudo apt install postgresql-client&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="c"&gt;# works because the port is bound to 127.0.0.1, i.e. reachable from the host itself&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-p&lt;/span&gt; 5432 &lt;span class="nt"&gt;-U&lt;/span&gt; appuser &lt;span class="nt"&gt;-d&lt;/span&gt; appdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From a remote machine&lt;/strong&gt;, since the port is bound to localhost only, tunnel in first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# forwards local port 5432 through SSH to the server's 127.0.0.1:5432&lt;/span&gt;
ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 5432:127.0.0.1:5432 youruser@your-server-ip

&lt;span class="c"&gt;# then, in a separate terminal on your local machine, connect as if Postgres were local:&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-p&lt;/span&gt; 5432 &lt;span class="nt"&gt;-U&lt;/span&gt; appuser &lt;span class="nt"&gt;-d&lt;/span&gt; appdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned / Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker compose down&lt;/code&gt; vs &lt;code&gt;docker compose down -v&lt;/code&gt;&lt;/strong&gt; — the first keeps your
named volume (data survives); the second deletes it too. Easy to fumble this
when you're tearing down containers to "start fresh."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing &lt;code&gt;POSTGRES_USER&lt;/code&gt;/&lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;/&lt;code&gt;POSTGRES_DB&lt;/code&gt; after the volume
already has data does nothing&lt;/strong&gt; — those env vars only take effect on first
init of an empty data directory. To change them later, update the values with
SQL directly, or wipe the volume and start over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never expose 5432 to &lt;code&gt;0.0.0.0&lt;/code&gt; on a public server.&lt;/strong&gt; Postgres auth is not
designed to be your only line of defense against internet-wide scanning and
brute-force attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are still your job.&lt;/strong&gt; A container restart is not a backup strategy.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="c"&gt;# dumps the database to a plain SQL file on the host, named with today's date&lt;/span&gt;
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;postgres_db pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; appuser appdb &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; backup_&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql

  &lt;span class="c"&gt;# restore into a fresh instance (-i keeps stdin open so the file content reaches psql)&lt;/span&gt;
  &lt;span class="nb"&gt;cat &lt;/span&gt;backup_2026-07-16.sql | docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; postgres_db psql &lt;span class="nt"&gt;-U&lt;/span&gt; appuser &lt;span class="nt"&gt;-d&lt;/span&gt; appdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt;&lt;/strong&gt; means Postgres comes back up after a server
reboot — but only if the Docker daemon itself is set to start on boot
(&lt;code&gt;sudo systemctl enable docker&lt;/code&gt;, usually on by default after the apt install).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://hub.docker.com/_/postgres" rel="noopener noreferrer"&gt;PostgreSQL Docker Hub image docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/reference/compose-file/" rel="noopener noreferrer"&gt;Docker Compose file reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;Docker Engine install docs for Ubuntu&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>postgres</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
