<?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: Ardiansyah Sulistyo</title>
    <description>The latest articles on DEV Community by Ardiansyah Sulistyo (@aardnsyhs).</description>
    <link>https://dev.to/aardnsyhs</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%2F3782117%2Fc2c99641-0f5d-4e36-a633-e1b8f9436406.jpg</url>
      <title>DEV Community: Ardiansyah Sulistyo</title>
      <link>https://dev.to/aardnsyhs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aardnsyhs"/>
    <language>en</language>
    <item>
      <title>A coding assistant drafted my Nginx config. Here’s what I still had to verify</title>
      <dc:creator>Ardiansyah Sulistyo</dc:creator>
      <pubDate>Sat, 05 Sep 2026 08:59:43 +0000</pubDate>
      <link>https://dev.to/aardnsyhs/a-coding-assistant-drafted-my-nginx-config-heres-what-i-still-had-to-verify-2e4j</link>
      <guid>https://dev.to/aardnsyhs/a-coding-assistant-drafted-my-nginx-config-heres-what-i-still-had-to-verify-2e4j</guid>
      <description>&lt;p&gt;I asked a coding assistant to draft an Nginx config for a small Laravel app on an Ubuntu VPS.&lt;/p&gt;

&lt;p&gt;It produced something usable in seconds.&lt;/p&gt;

&lt;p&gt;That is the useful part. The dangerous part is that a config can look completely reasonable, pass a quick visual scan, and still be wrong for the actual server.&lt;/p&gt;

&lt;p&gt;The assistant does not know my installed PHP version, the PHP-FPM socket on the machine, my directory layout, whether DNS already points at the VPS, or which headers my app and proxy setup need. It can make sensible guesses. Production infrastructure is where sensible guesses need to become verified facts.&lt;/p&gt;

&lt;p&gt;This is the review process I use before I let an AI-drafted Nginx config anywhere near a live site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;The setup is intentionally boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu VPS.&lt;/li&gt;
&lt;li&gt;Nginx as the public web server.&lt;/li&gt;
&lt;li&gt;A Laravel app in &lt;code&gt;/var/www/myapp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;PHP-FPM handling PHP requests.&lt;/li&gt;
&lt;li&gt;A non-root &lt;code&gt;deploy&lt;/code&gt; user owning the app code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical AI-generated starting point looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/myapp/public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.php&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$query_string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="nc"&gt;snippets/fastcgi-php&lt;/span&gt;&lt;span class="s"&gt;.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.2-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$realpath_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing obviously bad about it. In fact, this is close to what I would write manually.&lt;/p&gt;

&lt;p&gt;But “close” is not the same as correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. I verify the document root
&lt;/h2&gt;

&lt;p&gt;For Laravel, the Nginx &lt;code&gt;root&lt;/code&gt; must point to the &lt;code&gt;public&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/myapp/public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/myapp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an easy detail for an assistant to get right, but I still check it because a wrong root can expose files that should never be served publicly: &lt;code&gt;.env&lt;/code&gt;, source code, Composer files, or internal directories.&lt;/p&gt;

&lt;p&gt;On the server, I verify the path exists and contains Laravel’s entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /var/www/myapp/public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expect to see &lt;code&gt;index.php&lt;/code&gt; there.&lt;/p&gt;

&lt;p&gt;I also check the app’s actual deployment path instead of assuming it matches the example. A config generated for &lt;code&gt;/var/www/app&lt;/code&gt; does not magically become correct because I pasted it into a file named &lt;code&gt;myapp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. I verify the actual PHP-FPM socket
&lt;/h2&gt;

&lt;p&gt;This is probably the most common “looks fine but returns 502” problem.&lt;/p&gt;

&lt;p&gt;An assistant may assume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.2-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the server might have PHP 8.1, 8.3, or a custom pool/socket configuration.&lt;/p&gt;

&lt;p&gt;Before using the config, I check what exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /run/php/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I might see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;php8.3-fpm.sock
php8.3-fpm.pid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In that case, the Nginx config needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.3-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also confirm PHP-FPM itself is healthy:&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 status php8.3-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact service name depends on the installed version. This is why I do not trust a hard-coded PHP version from an AI response, a blog post, or an old config copied from another VPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. I verify &lt;code&gt;server_name&lt;/code&gt; and the default site
&lt;/h2&gt;

&lt;p&gt;A correct server block can still be ignored if Nginx does not match the incoming host header.&lt;/p&gt;

&lt;p&gt;I check the intended domains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I make sure the DNS records point to the VPS before treating the domain test as meaningful.&lt;/p&gt;

&lt;p&gt;I also disable the default Nginx site once my own server block is ready:&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 rm&lt;/span&gt; /etc/nginx/sites-enabled/default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is optional if I deliberately want the default site, but leaving it enabled has confused me more than once during initial setup. A request can hit the default virtual host instead of the app and make it look as though the app config is broken.&lt;/p&gt;

&lt;p&gt;To inspect what Nginx is actually loading, I use:&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;nginx &lt;span class="nt"&gt;-T&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a faster targeted check:&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;nginx &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 15 &lt;span class="s1"&gt;'server_name example.com'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is more useful than staring at the file I intended to enable. The effective configuration is what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. I verify the Laravel routing rule
&lt;/h2&gt;

&lt;p&gt;This line is the core of a normal Laravel Nginx setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$query_string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lets Nginx serve real static files directly and forwards routes that do not map to files into Laravel’s front controller.&lt;/p&gt;

&lt;p&gt;Without it, routes such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/dashboard
https://example.com/settings/profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can return 404 even though they work locally through Laravel’s development server.&lt;/p&gt;

&lt;p&gt;I test at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The home page.&lt;/li&gt;
&lt;li&gt;One regular application route.&lt;/li&gt;
&lt;li&gt;A static asset such as CSS or an image.&lt;/li&gt;
&lt;li&gt;A route with query parameters, if the app uses them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A generated config can be syntactically valid and still miss the behavior the framework expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. I verify ownership and writable directories
&lt;/h2&gt;

&lt;p&gt;Nginx does not run Laravel alone. PHP-FPM executes PHP requests, and Laravel needs write access to some directories.&lt;/p&gt;

&lt;p&gt;For a typical Laravel deployment, I check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; deploy:deploy /var/www/myapp
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; ug+rwx /var/www/myapp/storage /var/www/myapp/bootstrap/cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The right ownership model depends on how PHP-FPM is configured on the server. I do not treat the commands above as universal copy-paste instructions.&lt;/p&gt;

&lt;p&gt;What I actually verify is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which user owns the application files.&lt;/li&gt;
&lt;li&gt;Which user/group PHP-FPM workers run as.&lt;/li&gt;
&lt;li&gt;Whether the application can write to &lt;code&gt;storage/&lt;/code&gt; and &lt;code&gt;bootstrap/cache/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Whether I have accidentally made the whole project world-writable just to make an error disappear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Laravel shows a generic 500 error after Nginx is configured, I check the Laravel log before changing random Nginx directives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 /var/www/myapp/storage/logs/laravel.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A permissions issue often looks like an application error, not an Nginx error.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. I add proxy headers only when the app is behind a proxy
&lt;/h2&gt;

&lt;p&gt;For a Laravel app served directly by PHP-FPM, I do not need a &lt;code&gt;proxy_pass&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;For a Node app behind Nginx, I do. The basic pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those headers are not decorative. They affect what the application sees as the request host, client address, and original scheme.&lt;/p&gt;

&lt;p&gt;I check whether the app needs to trust its proxy before assuming HTTPS URLs, redirects, or secure cookies will behave correctly. This is especially important if TLS terminates at Nginx or another layer in front of the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. I do not let the assistant “solve” TLS by guessing
&lt;/h2&gt;

&lt;p&gt;The first Nginx config often listens on HTTP only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is fine as an initial step. It is not the final state for a public application that handles login or user data.&lt;/p&gt;

&lt;p&gt;I usually get HTTP working first, then configure TLS with a certificate provider such as Let’s Encrypt. The exact commands and configuration depend on the domain, DNS state, firewall rules, and whether another proxy/CDN is involved.&lt;/p&gt;

&lt;p&gt;This is a good example of where an AI response can be dangerously confident. It may tell you to paste a certificate path that does not exist yet, redirect all traffic before certificate issuance succeeds, or assume a particular Certbot setup.&lt;/p&gt;

&lt;p&gt;My rule is simple: treat the TLS instructions as a plan to verify, not a deployment command to paste blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. I test before every reload
&lt;/h2&gt;

&lt;p&gt;Before I reload Nginx, I run:&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;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it passes:&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 nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I test the response:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Once HTTPS is configured:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;I also keep an eye on logs while testing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/nginx/error.log
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/nginx/access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an app-specific setup, I prefer separate logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/myapp-access.log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;error_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/myapp-error.log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I can debug one site without digging through unrelated traffic from every server block.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI was actually useful for
&lt;/h2&gt;

&lt;p&gt;The coding assistant was useful as a fast first draft. It helped me get a standard config shape without opening old repositories or searching for a template.&lt;/p&gt;

&lt;p&gt;It was less useful as an authority on the live server.&lt;/p&gt;

&lt;p&gt;The work that still mattered was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Matching paths to the actual deployment.&lt;/li&gt;
&lt;li&gt;Checking the installed PHP-FPM version and socket.&lt;/li&gt;
&lt;li&gt;Confirming DNS and Nginx virtual-host selection.&lt;/li&gt;
&lt;li&gt;Verifying application permissions.&lt;/li&gt;
&lt;li&gt;Testing framework routes, static files, and error behavior.&lt;/li&gt;
&lt;li&gt;Reading the actual server and application logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not a criticism of coding assistants. It is just the boundary I try to keep clear: they can draft infrastructure configuration, but they cannot know my machine better than the machine itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  My small review checklist
&lt;/h2&gt;

&lt;p&gt;Before I accept an AI-drafted Nginx config for a Laravel app, I check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does &lt;code&gt;root&lt;/code&gt; point to the Laravel &lt;code&gt;public/&lt;/code&gt; directory?&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;fastcgi_pass&lt;/code&gt; match a real PHP-FPM socket on this server?&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;server_name&lt;/code&gt; match the actual domain, and does DNS point here?&lt;/li&gt;
&lt;li&gt;Is the intended site enabled, and is a default site catching my request instead?&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;try_files&lt;/code&gt; route application URLs through &lt;code&gt;index.php&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Can the app write to &lt;code&gt;storage/&lt;/code&gt; and &lt;code&gt;bootstrap/cache/&lt;/code&gt; without unsafe permissions?&lt;/li&gt;
&lt;li&gt;Did I run &lt;code&gt;sudo nginx -t&lt;/code&gt; before reloading?&lt;/li&gt;
&lt;li&gt;Did I test a real request and inspect the relevant logs?&lt;/li&gt;
&lt;li&gt;Is TLS configured and verified before I treat the site as ready?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The assistant can write the first 80% of a config in a few seconds. The remaining 20% is where production incidents tend to hide.&lt;/p&gt;

&lt;p&gt;What infrastructure changes do you let coding assistants make directly, and what do you always verify by hand?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>nginx</category>
      <category>laravel</category>
    </item>
    <item>
      <title>Deploying Next.js on a VPS: The 12 Things Nobody Tells You</title>
      <dc:creator>Ardiansyah Sulistyo</dc:creator>
      <pubDate>Thu, 03 Sep 2026 12:25:03 +0000</pubDate>
      <link>https://dev.to/aardnsyhs/deploying-nextjs-on-a-vps-the-12-things-nobody-tells-you-6ge</link>
      <guid>https://dev.to/aardnsyhs/deploying-nextjs-on-a-vps-the-12-things-nobody-tells-you-6ge</guid>
      <description>&lt;p&gt;Moving a Next.js app off Vercel and onto a plain Ubuntu VPS usually starts with a painful realization: either your serverless functions are timing out on background jobs, or your client just handed you a strict "you must host this on our infrastructure" requirement.&lt;br&gt;
Deploying the app itself is easy. What trips people up (and what cost me hours of debugging and locking myself out of my own server) is everything &lt;em&gt;around&lt;/em&gt; the app. &lt;br&gt;
Here are the 12 things that actually break when you leave the serverless ecosystem, in the order you'll hit them.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Next.js needs a process manager, not just &lt;code&gt;npm start&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;npm start&lt;/code&gt; in a terminal dies the moment you disconnect. You need&lt;br&gt;
something that keeps the process alive, restarts it on crash, and survives&lt;br&gt;
a reboot. &lt;strong&gt;PM2&lt;/strong&gt; is the simplest option for a single-server Node deploy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pm2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ecosystem.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node_modules/.bin/next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/var/www/my-app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;exec_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fork&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_memory_restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;512M&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/my-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd &lt;span class="nt"&gt;-u&lt;/span&gt; YOUR_USER &lt;span class="nt"&gt;--hp&lt;/span&gt; /home/YOUR_USER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one people forget - without it, PM2's process list doesn't survive a server reboot.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Nginx needs to proxy to the port, not serve the files
&lt;/h2&gt;

&lt;p&gt;Next.js is not a static site (unless you've explicitly exported it as&lt;br&gt;
one). Nginx's job is to forward requests to the Node process, not serve&lt;br&gt;
files from disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;nextjs_upstream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://nextjs_upstream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# WebSocket support - required for HMR and any realtime features&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;"upgrade"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Forgetting the WebSocket upgrade headers breaks more than dev mode
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header Upgrade $http_upgrade;&lt;/code&gt; and &lt;code&gt;Connection "upgrade"&lt;/code&gt; aren't&lt;br&gt;
just for Fast Refresh in development. Any app using WebSockets or&lt;br&gt;
Server-Sent Events in production (chat, live notifications, streaming AI&lt;br&gt;
responses) silently breaks without these two lines. This is one of the&lt;br&gt;
most common "works locally, broken in prod" bugs.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. &lt;code&gt;client_max_body_size&lt;/code&gt; - the silent 413 error
&lt;/h2&gt;

&lt;p&gt;Nginx defaults to a 1MB request body limit. File uploads, image processing&lt;br&gt;
endpoints, and some API routes will fail with a &lt;code&gt;413 Request Entity Too&lt;br&gt;
Large&lt;/code&gt; - and &lt;em&gt;only&lt;/em&gt; in production, since your local dev server has no such&lt;br&gt;
limit. Set it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;25M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. &lt;code&gt;proxy_buffering off&lt;/code&gt; for streaming responses
&lt;/h2&gt;

&lt;p&gt;If you're streaming a response (Server-Sent Events, streaming LLM&lt;br&gt;
completions via the Vercel AI SDK, etc.), Nginx's default buffering will&lt;br&gt;
hold the entire response before sending it to the client - defeating the&lt;br&gt;
purpose of streaming. Turn it off for the relevant location block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. DNS propagation is the #1 cause of "SSL setup failed"
&lt;/h2&gt;

&lt;p&gt;Before running Certbot, verify your domain's A record actually points at&lt;br&gt;
the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short example.com
&lt;span class="c"&gt;# should print your server's public IP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it doesn't match, Certbot's HTTP-01 challenge will fail - not because&lt;br&gt;
of a config error, but because Let's Encrypt can't reach your server at&lt;br&gt;
that domain yet. This single check saves more support tickets than&lt;br&gt;
anything else on this list.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. UFW must allow SSH before you enable it
&lt;/h2&gt;

&lt;p&gt;This one is a rite of passage:&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 allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;   &lt;span class="c"&gt;# only after the rules above are in place&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable UFW before allowing SSH and you'll lock yourself out of your own&lt;br&gt;
server. Always add the SSH rule first.&lt;/p&gt;
&lt;h2&gt;
  
  
  8. Certbot's Nginx plugin edits your config for you - read the diff
&lt;/h2&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;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; example.com &lt;span class="nt"&gt;-d&lt;/span&gt; www.example.com &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--agree-tos&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; you@example.com &lt;span class="nt"&gt;--redirect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This adds the SSL server block, the HTTP→HTTPS redirect, and the&lt;br&gt;
certificate paths automatically. Run &lt;code&gt;nginx -t&lt;/code&gt; afterward to confirm it's&lt;br&gt;
still valid, and glance at &lt;code&gt;/etc/nginx/sites-available/example.com&lt;/code&gt; once&lt;br&gt;
so you know what changed.&lt;/p&gt;
&lt;h2&gt;
  
  
  9. Certbot renewal isn't automatic until you verify it
&lt;/h2&gt;

&lt;p&gt;Certbot installs a systemd timer, but "installed" isn't the same as&lt;br&gt;
"working." Verify it explicitly:&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 status certbot.timer
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the dry run fails, your certificate &lt;em&gt;will&lt;/em&gt; expire in 90 days without&lt;br&gt;
you knowing until browsers start showing warnings to your users.&lt;/p&gt;
&lt;h2&gt;
  
  
  10. Fail2Ban's default backend breaks on Ubuntu 24.04
&lt;/h2&gt;

&lt;p&gt;Ubuntu 22.04 and earlier used file-based auth logs (&lt;code&gt;/var/log/auth.log&lt;/code&gt;).&lt;br&gt;
24.04 moved logging fully to the systemd journal. If your Fail2Ban jail is&lt;br&gt;
still configured for the file backend, it silently monitors a log that&lt;br&gt;
barely gets written to - and bans nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/jail.d/sshd.local
&lt;/span&gt;&lt;span class="nn"&gt;[sshd]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&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="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;systemd&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it's actually watching:&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;fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  11. &lt;code&gt;next/image&lt;/code&gt; is agonizingly slow without &lt;code&gt;sharp&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;On Vercel, image optimization just works. On a raw VPS, if you use the &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component without installing &lt;code&gt;sharp&lt;/code&gt;, Next.js falls back to a purely JavaScript-based image optimizer. It is incredibly slow and eats up your CPU.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Add it to your production dependencies. If you don't, your $6 VPS will easily spike to 100% CPU utilization just trying to serve a few optimized avatars.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Your app builds fine locally and OOMs on a 1GB VPS
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next build&lt;/code&gt; on a $6/month 1GB RAM droplet frequently gets killed by the&lt;br&gt;
OOM reaper mid-build - with no clear error, just a dead process. If you&lt;br&gt;
don't have a CI pipeline building elsewhere, add swap before your first&lt;br&gt;
build:&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; 2G /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;






&lt;h2&gt;
  
  
  The checklist, if you're tired of doing this manually
&lt;/h2&gt;

&lt;p&gt;I got tired of locking myself out of UFW, forgetting to set the WebSocket headers, and missing the systemd fixes. So, I bundled this exact Next.js setup (Nginx vhost, UFW, Fail2Ban, Swap, and Certbot) into a single bash script. &lt;/p&gt;

&lt;p&gt;I also added one thing this checklist can't: a Telegram notification the moment anyone logs into the server over SSH.&lt;/p&gt;

&lt;p&gt;You can grab the &lt;a href="https://aardnsyhs.gumroad.com/l/serversecure" rel="noopener noreferrer"&gt;ServerSecure Setup here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This article is the complete free version of the checklist - nothing here is paywalled. The script is just for when you'd rather spend those 15 terminal commands building your actual product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Running into something not covered here? Drop it in the comments - I'll add it to the list.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>nginx</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Get a Telegram Notification Every Time Someone SSHes Into Your Server</title>
      <dc:creator>Ardiansyah Sulistyo</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:23:32 +0000</pubDate>
      <link>https://dev.to/aardnsyhs/get-a-telegram-notification-every-time-someone-sshes-into-your-server-2pji</link>
      <guid>https://dev.to/aardnsyhs/get-a-telegram-notification-every-time-someone-sshes-into-your-server-2pji</guid>
      <description>&lt;p&gt;A fresh VPS starts receiving SSH brute-force attempts within minutes of&lt;br&gt;
going public. Most of us find out about a breach &lt;em&gt;after&lt;/em&gt; the damage -&lt;br&gt;
a spike in outbound traffic, a suspended hosting account, a client asking&lt;br&gt;
why their site is serving spam.&lt;/p&gt;

&lt;p&gt;What if you just... knew? The second someone authenticates?&lt;/p&gt;

&lt;p&gt;Here's a complete, working setup that sends a Telegram message to your&lt;br&gt;
phone every time a user successfully logs in over SSH - key or password,&lt;br&gt;
interactive or automated. It's about 30 lines of Bash plus one line of PAM&lt;br&gt;
config. No agent, no daemon, no third-party service beyond Telegram's free&lt;br&gt;
Bot API.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;SSH login events pass through &lt;strong&gt;PAM&lt;/strong&gt; (Pluggable Authentication Modules)&lt;br&gt;
on Linux. PAM has a module called &lt;code&gt;pam_exec&lt;/code&gt; that can run an arbitrary&lt;br&gt;
script at specific points in the auth lifecycle - including&lt;br&gt;
&lt;code&gt;open_session&lt;/code&gt;, which fires only &lt;em&gt;after&lt;/em&gt; a successful login.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/etc/pam.d/sshd
  └─ pam_exec (open_session) ──▶ /usr/local/bin/ssh-notify
                                       └─ curl → Telegram Bot API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire architecture. No polling, no log-tailing, no extra&lt;br&gt;
processes running in the background.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 - Create a Telegram bot (2 minutes)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open Telegram, search for &lt;strong&gt;&lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Send &lt;code&gt;/newbot&lt;/code&gt;, follow the prompts, name it whatever you like&lt;/li&gt;
&lt;li&gt;Copy the token it gives you - looks like &lt;code&gt;123456789:ABCdefGHIjklMNOpqrSTUvwxYZ&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now get your chat ID:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send any message to your new bot&lt;/li&gt;
&lt;li&gt;Visit &lt;code&gt;https://api.telegram.org/bot&amp;lt;YOUR_TOKEN&amp;gt;/getUpdates&lt;/code&gt; in a browser&lt;/li&gt;
&lt;li&gt;Find &lt;code&gt;"chat":{"id":YOUR_CHAT_ID}&lt;/code&gt; in the JSON response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it - no app registration, no OAuth flow, no approval process.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2 - Store the credentials securely
&lt;/h2&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/serversecure
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/serversecure/telegram.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;'
BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
CHAT_ID="987654321"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/serversecure/telegram.conf
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/serversecure/telegram.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Mode &lt;code&gt;600&lt;/code&gt;, owned by &lt;code&gt;root&lt;/code&gt; - the bot token is effectively a password.&lt;br&gt;
Never commit this file to version control.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3 - The notification script
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;/usr/local/bin/ssh-notify&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# ssh-notify - Telegram SSH login alert via pam_exec&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# CRITICAL: this script MUST exit 0 under all circumstances.&lt;/span&gt;
&lt;span class="c"&gt;# A non-zero exit code from a pam_exec hook can block the login.&lt;/span&gt;

&lt;span class="nb"&gt;readonly &lt;/span&gt;&lt;span class="nv"&gt;CONF_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/etc/serversecure/telegram.conf"&lt;/span&gt;
&lt;span class="nb"&gt;readonly &lt;/span&gt;&lt;span class="nv"&gt;STATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/lib/ssh-notify"&lt;/span&gt;

main&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;# Only fire on successful login, not on session close&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PAM_TYPE&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"open_session"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        return &lt;/span&gt;0
    &lt;span class="k"&gt;fi&lt;/span&gt;

    &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONF_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
    &lt;span class="c"&gt;# shellcheck source=/dev/null&lt;/span&gt;
    &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONF_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CHAT_ID&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

    &lt;span class="c"&gt;# --- Rate limiting: collapse repeat alerts within 60s ---&lt;/span&gt;
    &lt;span class="c"&gt;# Tools like VS Code Remote SSH or rsync open multiple sessions per&lt;/span&gt;
    &lt;span class="c"&gt;# connection. Without this, one "login" becomes four notifications.&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PAM_USER&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;unknown&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;rhost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PAM_RHOST&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;unknown&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;key
    &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;rhost&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'[:alnum:]@.'&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;state_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STATE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;state_file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;local &lt;/span&gt;last now elapsed
        &lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;state_file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="nv"&gt;elapsed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; now &lt;span class="o"&gt;-&lt;/span&gt; last &lt;span class="k"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;elapsed&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 60 &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STATE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
    &lt;span class="nb"&gt;date&lt;/span&gt; +%s &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;state_file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null

    &lt;span class="c"&gt;# --- Build the alert ---&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;host ip
    &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 3 https://ifconfig.me 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"unknown"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"🔐 *SSH Login Alert - &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;*

🖥️ Server: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;)
👤 User: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;
🌐 Source IP: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;rhost&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;
🕒 Time: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d %H:%M:%S UTC'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="c"&gt;# --- Send it async, hard-timeout, never block the login ---&lt;/span&gt;
    &lt;span class="o"&gt;(&lt;/span&gt;
        setsid curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"chat_id=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CHAT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"parse_mode=Markdown"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"text=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
    &lt;span class="o"&gt;)&lt;/span&gt; &amp;amp;

    &lt;span class="k"&gt;return &lt;/span&gt;0
&lt;span class="o"&gt;}&lt;/span&gt;

main
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /usr/local/bin/ssh-notify
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /usr/local/bin/ssh-notify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three design decisions worth calling out, because they're the difference&lt;br&gt;
between "cool script" and "thing you can trust in production":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It always exits 0.&lt;/strong&gt; A pam_exec hook that returns non-zero can be
configured to block the login it's supposed to be reporting on. This
script fails silently, always, no exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The curl call runs in a detached subshell (&lt;code&gt;setsid ... &amp;amp;&lt;/code&gt;).&lt;/strong&gt; If
Telegram's API is slow or your network hiccups, your SSH login is never
delayed waiting on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting by IP+user.&lt;/strong&gt; Without this, tools that open multiple
SSH channels per "connection" (VS Code Remote is the worst offender)
will spam you with 3-4 notifications for what is, to you, one login.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 4 - Wire it into PAM
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"session    optional     pam_exec.so   seteuid /usr/local/bin/ssh-notify"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/pam.d/sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;optional&lt;/code&gt; control flag is important - it tells PAM this module's&lt;br&gt;
success or failure has no bearing on whether the login proceeds.&lt;/p&gt;

&lt;p&gt;Restart nothing - PAM config is read per-session, so your &lt;em&gt;next&lt;/em&gt; SSH login&lt;br&gt;
will trigger it. Test with a login from another terminal window; you&lt;br&gt;
should get a Telegram message within a couple of seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover (and what would)
&lt;/h2&gt;

&lt;p&gt;This gets you real-time visibility. It doesn't get you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Geolocation on the source IP&lt;/strong&gt; - doable with a free IP lookup API,
left out here to avoid a third-party dependency by default (and for
privacy - you're now sending login IPs to another service).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"New device" flagging&lt;/strong&gt; - tracking previously-seen IPs and flagging
first-time sources needs a small persistent IP list, easy to add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail2Ban ban alerts, sudo usage alerts, disk-full alerts&lt;/strong&gt; - same
pattern, different trigger. PAM's &lt;code&gt;pam_exec&lt;/code&gt; only covers auth events;
these need to hook into &lt;code&gt;sudo&lt;/code&gt;'s own PAM stack or a cron check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want all of that plus the rest of the hardening that should happen&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; you ever expose SSH to the internet - non-root user setup, UFW,&lt;br&gt;
Fail2Ban jails, automatic SSL - that's the larger tool this is one module&lt;br&gt;
of: &lt;a href="https://aardnsyhs.gumroad.com/l/serversecure" rel="noopener noreferrer"&gt;ServerSecure Setup&lt;/a&gt;. This snippet is the full&lt;br&gt;
signature feature, free, because it costs me nothing to give away and it's&lt;br&gt;
the best proof I can offer that the rest of the tool is built with the&lt;br&gt;
same care.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions or found an edge case? Drop a comment - I read and reply to&lt;br&gt;
every one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>bash</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why Most Cafe Websites Feel Outdated (And How I Fixed It)</title>
      <dc:creator>Ardiansyah Sulistyo</dc:creator>
      <pubDate>Tue, 19 May 2026 08:09:52 +0000</pubDate>
      <link>https://dev.to/aardnsyhs/why-most-cafe-websites-feel-outdated-and-how-i-fixed-it-im2</link>
      <guid>https://dev.to/aardnsyhs/why-most-cafe-websites-feel-outdated-and-how-i-fixed-it-im2</guid>
      <description>&lt;p&gt;Most cafe websites still look like generic business templates from 2016.&lt;/p&gt;

&lt;p&gt;Bright backgrounds, cluttered layouts, autoplay sliders, weak typography, and mobile experiences that feel like an afterthought.&lt;/p&gt;

&lt;p&gt;I wanted to create something that feels modern, cinematic, and mobile-first — more like a premium app experience than a traditional website.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Noir &amp;amp; Brew&lt;/strong&gt;, a premium Astro landing page template for modern coffee shops and creative F&amp;amp;B brands.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Problem With Most Cafe Websites
&lt;/h1&gt;

&lt;p&gt;Most cafe websites follow the same pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generic layouts&lt;/li&gt;
&lt;li&gt;Poor mobile navigation&lt;/li&gt;
&lt;li&gt;Weak visual hierarchy&lt;/li&gt;
&lt;li&gt;Overcrowded sections&lt;/li&gt;
&lt;li&gt;Inconsistent branding&lt;/li&gt;
&lt;li&gt;Slow performance&lt;/li&gt;
&lt;li&gt;Outdated UI patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result?&lt;/p&gt;

&lt;p&gt;They don’t feel memorable.&lt;/p&gt;

&lt;p&gt;And for cafes, atmosphere matters just as much as the product itself.&lt;/p&gt;

&lt;p&gt;A website should communicate the same feeling customers get when they walk into the space:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;warm lighting&lt;/li&gt;
&lt;li&gt;premium ambiance&lt;/li&gt;
&lt;li&gt;calm atmosphere&lt;/li&gt;
&lt;li&gt;intentional branding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That became the core direction behind this project.&lt;/p&gt;

&lt;h1&gt;
  
  
  Designing Around Atmosphere
&lt;/h1&gt;

&lt;p&gt;Instead of focusing only on information density, I focused on visual mood and presentation first.&lt;/p&gt;

&lt;p&gt;The design direction was inspired by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;editorial layouts&lt;/li&gt;
&lt;li&gt;luxury hospitality brands&lt;/li&gt;
&lt;li&gt;cinematic dark interfaces&lt;/li&gt;
&lt;li&gt;premium coffee packaging&lt;/li&gt;
&lt;li&gt;modern mobile app navigation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I intentionally used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dark backgrounds&lt;/li&gt;
&lt;li&gt;warm gold accents&lt;/li&gt;
&lt;li&gt;oversized typography&lt;/li&gt;
&lt;li&gt;immersive cards&lt;/li&gt;
&lt;li&gt;spacious layouts&lt;/li&gt;
&lt;li&gt;minimal UI noise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Make the website feel premium before users even start reading.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Mobile-First Was the Priority
&lt;/h1&gt;

&lt;p&gt;Most restaurant traffic comes from mobile devices.&lt;/p&gt;

&lt;p&gt;But many templates still treat mobile as a scaled-down desktop layout.&lt;/p&gt;

&lt;p&gt;I did the opposite.&lt;/p&gt;

&lt;p&gt;I designed the mobile experience first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bottom navigation&lt;/li&gt;
&lt;li&gt;app-like interaction&lt;/li&gt;
&lt;li&gt;compact content blocks&lt;/li&gt;
&lt;li&gt;thumb-friendly spacing&lt;/li&gt;
&lt;li&gt;immersive promo sections&lt;/li&gt;
&lt;li&gt;simplified menu browsing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after the mobile structure felt complete did I expand the design into desktop.&lt;/p&gt;

&lt;p&gt;That decision alone made the experience feel significantly more modern.&lt;/p&gt;

&lt;h1&gt;
  
  
  Tech Stack
&lt;/h1&gt;

&lt;p&gt;I wanted the template to stay lightweight while still feeling interactive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack Used
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Astro&lt;/li&gt;
&lt;li&gt;React&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4&lt;/li&gt;
&lt;li&gt;Framer Motion&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Astro?
&lt;/h2&gt;

&lt;p&gt;Astro gives excellent performance out of the box while still allowing interactive islands where needed.&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fast initial load&lt;/li&gt;
&lt;li&gt;less JavaScript shipped&lt;/li&gt;
&lt;li&gt;SEO-friendly pages&lt;/li&gt;
&lt;li&gt;smoother interactions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For content-focused websites like cafes, portfolios, and hospitality brands, this architecture works extremely well.&lt;/p&gt;

&lt;h1&gt;
  
  
  Features
&lt;/h1&gt;

&lt;p&gt;The template currently includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Responsive landing pages&lt;/li&gt;
&lt;li&gt;Mobile-first layouts&lt;/li&gt;
&lt;li&gt;Interactive menu section&lt;/li&gt;
&lt;li&gt;Promo showcase&lt;/li&gt;
&lt;li&gt;Contact &amp;amp; location pages&lt;/li&gt;
&lt;li&gt;3 built-in themes&lt;/li&gt;
&lt;li&gt;SEO-ready structure&lt;/li&gt;
&lt;li&gt;Easy customization&lt;/li&gt;
&lt;li&gt;Production-ready codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Designed for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cafes&lt;/li&gt;
&lt;li&gt;coffee shops&lt;/li&gt;
&lt;li&gt;restaurants&lt;/li&gt;
&lt;li&gt;hospitality brands&lt;/li&gt;
&lt;li&gt;creative studios&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Challenges During Development
&lt;/h1&gt;

&lt;p&gt;One of the hardest parts was balancing aesthetics with readability.&lt;/p&gt;

&lt;p&gt;Dark luxury interfaces can easily become:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;too low contrast&lt;/li&gt;
&lt;li&gt;visually noisy&lt;/li&gt;
&lt;li&gt;difficult to scan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had to simplify multiple sections repeatedly before the interface finally felt clean enough.&lt;/p&gt;

&lt;p&gt;Another challenge was avoiding over-animation.&lt;/p&gt;

&lt;p&gt;Modern websites often animate everything.&lt;/p&gt;

&lt;p&gt;Instead, I focused on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;subtle transitions&lt;/li&gt;
&lt;li&gt;layered depth&lt;/li&gt;
&lt;li&gt;hover feedback&lt;/li&gt;
&lt;li&gt;visual rhythm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That helped the UI feel calmer and more premium.&lt;/p&gt;

&lt;h1&gt;
  
  
  Final Thoughts
&lt;/h1&gt;

&lt;p&gt;I think websites for physical spaces should feel emotional — not just functional.&lt;/p&gt;

&lt;p&gt;Especially for cafes.&lt;/p&gt;

&lt;p&gt;People don’t only buy coffee.&lt;/p&gt;

&lt;p&gt;They buy atmosphere, identity, and experience.&lt;/p&gt;

&lt;p&gt;That’s what I tried to translate into this template.&lt;/p&gt;

&lt;h1&gt;
  
  
  Live Demo
&lt;/h1&gt;

&lt;p&gt;🔗 Demo:&lt;br&gt;
&lt;a href="https://cafe-template-sigma.vercel.app" rel="noopener noreferrer"&gt;Noir &amp;amp; Brew Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🔗 Gumroad:&lt;br&gt;
&lt;a href="https://aardnsyhs.gumroad.com/l/noir-and-brew-cafe-template" rel="noopener noreferrer"&gt;Noir &amp;amp; Brew Template&lt;/a&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>react</category>
      <category>tailwindcss</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a minimal company profile template because I was tired of colorful noise</title>
      <dc:creator>Ardiansyah Sulistyo</dc:creator>
      <pubDate>Fri, 20 Feb 2026 07:33:36 +0000</pubDate>
      <link>https://dev.to/aardnsyhs/i-built-a-minimal-company-profile-template-because-i-was-tired-of-colorful-noise-40b1</link>
      <guid>https://dev.to/aardnsyhs/i-built-a-minimal-company-profile-template-because-i-was-tired-of-colorful-noise-40b1</guid>
      <description>&lt;p&gt;🔗 &lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://mono-profile-alpha.vercel.app/" rel="noopener noreferrer"&gt;https://mono-profile-alpha.vercel.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have a problem with most website templates.&lt;/p&gt;

&lt;p&gt;They're loud. Gradients everywhere, hero sections with three different&lt;br&gt;
typefaces, accent colors fighting each other. I get it they want to&lt;br&gt;
look impressive in a marketplace thumbnail.&lt;/p&gt;

&lt;p&gt;But when I actually need to &lt;em&gt;use&lt;/em&gt; one? It's exhausting.&lt;/p&gt;

&lt;p&gt;I like monochrome. Black, white, grays, maybe one muted accent.&lt;br&gt;
Clean type. Room to breathe. So after saying "I'll just build my own"&lt;br&gt;
for the fifth time, I actually did.&lt;/p&gt;

&lt;p&gt;This is MONO, a minimal company profile template I built for myself&lt;br&gt;
and ended up packaging for anyone else who thinks the same way.&lt;/p&gt;

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

&lt;p&gt;I went with what I'm comfortable with, and what I'd actually want&lt;br&gt;
to inherit from someone else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt; — fast dev server, no config hell&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React + TypeScript&lt;/strong&gt; — predictable, easy to extend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt; — utility-first, no surprise CSS specificity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shadcn/ui&lt;/strong&gt; — accessible components without opinionated styling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shadcn was actually the right call here. Since the components are&lt;br&gt;
unstyled by default and live in your codebase, the monochrome&lt;br&gt;
aesthetic doesn't fight the component library. You're not overriding&lt;br&gt;
a blue primary button everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the template
&lt;/h2&gt;

&lt;p&gt;It's a typical company profile page, but intentionally stripped down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hero (headline, subtext, CTA)&lt;/li&gt;
&lt;li&gt;Services / what you offer&lt;/li&gt;
&lt;li&gt;Selected work / portfolio&lt;/li&gt;
&lt;li&gt;About section&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Contact&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No unnecessary animations, no scroll-jacking, no&lt;br&gt;
"surprise me" interactions.&lt;/p&gt;

&lt;p&gt;The section order makes sense for most businesses: tell me who you&lt;br&gt;
are → what you do → proof you've done it → answer my obvious&lt;br&gt;
questions → let me reach you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design decision I'm most glad I made
&lt;/h2&gt;

&lt;p&gt;I almost added a color theme switcher. "Users can pick an accent&lt;br&gt;
color!" Sounds useful, right?&lt;/p&gt;

&lt;p&gt;I removed it.&lt;/p&gt;

&lt;p&gt;Not because it's hard to build it's not. But it introduces&lt;br&gt;
decisions. "Should I use purple or teal?" is exactly the kind of&lt;br&gt;
question I want the template to eliminate, not create.&lt;/p&gt;

&lt;p&gt;Monochrome is a decision. Stick to it.&lt;/p&gt;

&lt;p&gt;If you want a specific accent later, you change one Tailwind CSS&lt;br&gt;
variable and you're done. That's intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;Honestly? Anyone who needs a company profile that looks professional&lt;br&gt;
without spending a week on design decisions.&lt;/p&gt;

&lt;p&gt;That includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agencies putting together a quick client-facing profile&lt;/li&gt;
&lt;li&gt;Freelancers who want a site that looks serious&lt;/li&gt;
&lt;li&gt;Developers who'd rather write code than pick color palettes&lt;/li&gt;
&lt;li&gt;Someone building a startup landing page before they figure out branding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not a dashboard, not a SaaS template, not a landing page with&lt;br&gt;
17 sections. Just a company profile. Done well.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to grab it
&lt;/h2&gt;

&lt;p&gt;It's on Gumroad: &lt;strong&gt;[&lt;a href="https://aardnsyhs.gumroad.com/l/mono-company-profile" rel="noopener noreferrer"&gt;https://aardnsyhs.gumroad.com/l/mono-company-profile&lt;/a&gt;]&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Comes with the full source code, clean folder structure, and a README&lt;br&gt;
that explains how to customize the key parts without touching&lt;br&gt;
everything.&lt;/p&gt;

&lt;p&gt;If you check it out or if you've built something similar I'd love&lt;br&gt;
to hear what you think. What's the one thing you always end up&lt;br&gt;
rebuilding when you use someone else's template?&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>tailwindcss</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
