<?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: Kinsly</title>
    <description>The latest articles on DEV Community by Kinsly (@kinsly).</description>
    <link>https://dev.to/kinsly</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.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F11673%2F1ea05366-aaeb-4c03-a944-c0afb07dda54.png</url>
      <title>DEV Community: Kinsly</title>
      <link>https://dev.to/kinsly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kinsly"/>
    <language>en</language>
    <item>
      <title>💾 Why I Chose SQLite for My Startup — The Most Underrated Database You're Probably Ignoring</title>
      <dc:creator>Sergey Podgorny</dc:creator>
      <pubDate>Mon, 20 Oct 2025 16:15:00 +0000</pubDate>
      <link>https://dev.to/kinsly/why-i-chose-sqlite-for-my-startup-the-most-underrated-database-youre-probably-ignoring-21eo</link>
      <guid>https://dev.to/kinsly/why-i-chose-sqlite-for-my-startup-the-most-underrated-database-youre-probably-ignoring-21eo</guid>
      <description>&lt;p&gt;For years, I ignored SQLite. I assumed it was only good for toy apps, quick experiments, or maybe some desktop utilities. Like many developers, I immediately jumped to &lt;strong&gt;Postgres&lt;/strong&gt; or &lt;strong&gt;MySQL&lt;/strong&gt; for any "serious" project. I even paid for a managed &lt;strong&gt;AWS RDS&lt;/strong&gt; instance, believing I was preparing for scale.&lt;/p&gt;

&lt;p&gt;But over time, I learned something that changed how I build small and medium-sized systems:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;👉 &lt;em&gt;For 90% of applications, SQLite is not only enough — it's often better.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My entire application runs on a single VPS, serving just a few requests per second. Most startups never reach the mythical "millions of requests per minute". For this scale, running a full database server is like renting a truck to deliver a pizza.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 Why SQLite Is So Powerful
&lt;/h2&gt;

&lt;p&gt;SQLite is a &lt;strong&gt;serverless&lt;/strong&gt;, &lt;strong&gt;file-based&lt;/strong&gt;, &lt;strong&gt;self-contained&lt;/strong&gt; database engine. It's just a single file (e.g., &lt;code&gt;data.db&lt;/code&gt;) that your app reads and writes to — no network connections, no daemons, no setup.&lt;/p&gt;

&lt;p&gt;Yet it supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full &lt;strong&gt;ACID transactions&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign keys&lt;/strong&gt;, &lt;strong&gt;indexes&lt;/strong&gt;, &lt;strong&gt;views&lt;/strong&gt;, &lt;strong&gt;triggers&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Concurrency (especially in &lt;strong&gt;WAL mode&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gigabytes of data&lt;/strong&gt; and &lt;strong&gt;millions of rows&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it's &lt;strong&gt;blazing fast&lt;/strong&gt; when used correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ The Secret Sauce: WAL Mode
&lt;/h2&gt;

&lt;p&gt;By default, SQLite stores changes using a &lt;em&gt;rollback journal&lt;/em&gt; — it writes updates directly to the main database file and keeps a small backup journal during each transaction for safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WAL (Write-Ahead Logging)&lt;/strong&gt; changes this strategy completely.&lt;/p&gt;

&lt;p&gt;Instead of touching the main DB directly, all writes are appended to a separate &lt;code&gt;.db-wal&lt;/code&gt; file. Readers keep reading from the main file while writers add to the WAL log. Later, the changes are merged automatically.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Readers and writers don't block each other.&lt;/li&gt;
&lt;li&gt;Concurrent operations become much faster.&lt;/li&gt;
&lt;li&gt;Crashes don't corrupt your main data file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WAL mode can &lt;strong&gt;significantly boost performance&lt;/strong&gt; in multi-threaded or multi-request apps, like Go web servers, where reads and writes happen at the same time.&lt;/p&gt;

&lt;p&gt;But if your app is tiny and writes only occasionally, the default mode (&lt;code&gt;DELETE&lt;/code&gt;) is perfectly fine. You can always switch later with:&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="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;journal_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other journal modes include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DELETE&lt;/code&gt; (default on many builds): makes a rollback file, then deletes it after writing. When you start a transaction, SQLite writes a rollback journal file (&lt;code&gt;data.db-journal&lt;/code&gt;). After committing, it &lt;strong&gt;deletes&lt;/strong&gt; that file. It's the safest and most widely compatible default. This is what you get if you don't set &lt;code&gt;journal_mode&lt;/code&gt; at all.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TRUNCATE&lt;/code&gt;: same as DELETE, but instead of removing the journal file, it just truncates it to zero bytes and reuses it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PERSIST&lt;/code&gt;: same idea, but keeps the journal file contents and just overwrites a header.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MEMORY&lt;/code&gt;: journal is only in RAM → faster, but data can be lost on crash.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OFF&lt;/code&gt;: no journaling at all → faster, but unsafe if the app or OS crashes, the database can be corrupted on crash.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WAL&lt;/code&gt;: the special one we just discussed.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚙️ What Are PRAGMAs?
&lt;/h2&gt;

&lt;p&gt;In SQLite, &lt;strong&gt;PRAGMAs&lt;/strong&gt; are configuration switches that control database behavior — kind of like settings in a config file, but stored inside the DB engine itself.&lt;/p&gt;

&lt;p&gt;You can enable them either by running SQL commands:&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="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;synchronous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NORMAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or pass them directly in the Go connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;dsn&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"file:data.db?_pragma=busy_timeout(10000)&amp;amp;_pragma=foreign_keys(ON)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are a few useful ones:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;busy_timeout(10000)&lt;/code&gt; tells SQLite: &lt;em&gt;if the database is locked, wait up to 10 seconds before giving up&lt;/em&gt; (instead of failing immediately with &lt;code&gt;database is locked&lt;/code&gt;). Useful when multiple goroutines or processes may try to write at the same time. It is good for web apps; safer than retrying manually.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;journal_mode(WAL)&lt;/code&gt; enables Write-Ahead Logging instead of the &lt;code&gt;default&lt;/code&gt; rollback journaling. It makes reads/writes more concurrent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;journal_size_limit(200000000)&lt;/code&gt; sets a cap on the WAL file size (here: ~200 MB). Normally, the WAL file can grow until checkpointed (merged back into the main DB). This prevents it from growing forever. If your DB is small, it will never hit this size anyway.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;foreign_keys(ON)&lt;/code&gt; enables foreign key constraints (&lt;code&gt;OFF&lt;/code&gt; by default in SQLite). If you define relations like &lt;code&gt;user_id REFERENCES users(id)&lt;/code&gt;, this ensures referential integrity. It is always good practice if you use relationships.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;temp_store(MEMORY)&lt;/code&gt; tells SQLite to keep temporary data (like for &lt;code&gt;ORDER BY&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and indexes while querying) in RAM instead of on disk. Faster for queries, but uses more memory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;cache_size(-16000)&lt;/code&gt; sets the page cache size. The negative number means "KB" instead of pages. Here: &lt;code&gt;-16000&lt;/code&gt; = about &lt;code&gt;16 MB&lt;/code&gt; of cache. This is how much SQLite will keep in memory for speeding up queries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;synchronous(NORMAL)&lt;/code&gt; controls how careful SQLite is about flushing data to disk.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FULL&lt;/code&gt; (default): every write is forced to disk immediately → safest, but slowest.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NORMAL&lt;/code&gt;: doesn't flush on every step, but still durable enough for most apps (data is safe unless the OS/hardware crashes at a very unlucky moment).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OFF&lt;/code&gt;: fastest, but risk of corruption on crash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ℹ️ &lt;code&gt;NORMAL&lt;/code&gt; is a common compromise in web apps: faster inserts, still quite safe.&lt;/p&gt;

&lt;p&gt;Not all are required — start simple and tune only when needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 When You Should (and Shouldn't) Use SQLite
&lt;/h2&gt;

&lt;p&gt;SQLite is perfect for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small to medium web apps&lt;/li&gt;
&lt;li&gt;APIs on a single server&lt;/li&gt;
&lt;li&gt;Prototypes, MVPs, side projects&lt;/li&gt;
&lt;li&gt;Command-line or desktop tools&lt;/li&gt;
&lt;li&gt;Local caching layers or analytics snapshots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But you'll hit limits if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need multiple servers writing to the same DB file&lt;/li&gt;
&lt;li&gt;You expect thousands of writes per second&lt;/li&gt;
&lt;li&gt;You require fine-grained access control&lt;/li&gt;
&lt;li&gt;You need replication or clustering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's when Postgres or MySQL makes sense. Until then, SQLite saves you time, money, and complexity.&lt;/p&gt;




&lt;h2&gt;
  
  
  🐹 Example: Using SQLite with Go
&lt;/h2&gt;

&lt;p&gt;Let's set up a connection in Go using the excellent cgo-free driver &lt;code&gt;modernc.org/sqlite&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;$ go get modernc.org/sqlite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"database/sql"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="s"&gt;"modernc.org/sqlite"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Open a database file (or ":memory:" for in-memory)&lt;/span&gt;
    &lt;span class="n"&gt;dsn&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"file:data.db?_pragma=busy_timeout(5000)&amp;amp;_pragma=journal_mode(WAL)"&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sqlite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"open: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// Configure pool: keep a small pool for sqlite; tune for your workload&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxOpenConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// readers can have several&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxIdleConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetConnMaxLifetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Ping to verify connection&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PingContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ping: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DB open, WAL enabled and busy_timeout set (via DSN)."&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;SQLite allows many concurrent readers but only one writer at a time. A common pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;one DB instance&lt;/strong&gt; restricted to a single writer (&lt;code&gt;SetMaxOpenConns(1)&lt;/code&gt;) for all writes.&lt;/li&gt;
&lt;li&gt;Use a separate DB instance with a larger pool for readers.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// writer&lt;/span&gt;
&lt;span class="n"&gt;writerDSN&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"file:test.db?_pragma=busy_timeout(5000)&amp;amp;_pragma=journal_mode(WAL)"&lt;/span&gt;
&lt;span class="n"&gt;writerDB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sqlite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;writerDSN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;writerDB&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxOpenConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// single writer connection&lt;/span&gt;
&lt;span class="n"&gt;writerDB&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxIdleConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// readers&lt;/span&gt;
&lt;span class="n"&gt;readerDSN&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"file:test.db?_pragma=busy_timeout(5000)"&lt;/span&gt;
&lt;span class="n"&gt;readerDB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sqlite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;readerDSN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;readerDB&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxOpenConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// allow concurrent readers&lt;/span&gt;
&lt;span class="n"&gt;readerDB&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxIdleConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduces &lt;code&gt;SQLITE_BUSY&lt;/code&gt; when multiple goroutines try to write. Practical guides and community posts recommend separating writer/reader pools.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 Navigating SQLite from the Terminal
&lt;/h2&gt;

&lt;p&gt;SQLite also comes with a lightweight console client: sqlite3.&lt;/p&gt;

&lt;p&gt;Open your database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite3 data.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside, these dot-commands make life easier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.help             # list all available commands
.databases        # show loaded databases (usually just `main` pointing to your file)
.tables           # list all tables
.schema           # show schema of all tables
.schema table     # show schema of a specific table
.headers on       # turn on column headers in query output
.mode column      # format results in a nicely aligned table
.mode line        # show each row vertically, one field per line (great for wide tables)
.mode list         # results as plain text separated by | (default mode)
.width 20 30 15   # set column widths when using .mode column
.nullvalue NULL   # choose how NULLs are displayed
.quit or .exit    # leave the CLI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite&amp;gt; .headers on
sqlite&amp;gt; .mode column
sqlite&amp;gt; SELECT * FROM users;
id  email             created_at
--  ----------------  ---------------------
1   test@example.com  2025-10-07 14:30:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you learn &lt;code&gt;.headers on&lt;/code&gt; and &lt;code&gt;.mode column&lt;/code&gt;, the CLI feels surprisingly pleasant.&lt;/p&gt;

&lt;p&gt;If you prefer certain settings to be enabled by default, then it makes sense to customize the configuration file to suit your needs. If the initialization file &lt;code&gt;~/.sqliterc&lt;/code&gt; exists, &lt;code&gt;sqlite3&lt;/code&gt; will read it to set the configuration of the interactive environment. This file should generally only contain meta-commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.headers on
.mode column
.nullvalue NULL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🧩 Conclusion
&lt;/h2&gt;

&lt;p&gt;SQLite is like the pocketknife of databases — tiny, portable, and incredibly capable when used properly. For many apps, it's a &lt;strong&gt;smarter default&lt;/strong&gt; than running a full database server.&lt;/p&gt;

&lt;p&gt;For Kinsly's current stage, SQLite is the perfect choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's blazing fast, even on cheap VPS hardware&lt;/li&gt;
&lt;li&gt;It simplifies deployment&lt;/li&gt;
&lt;li&gt;It requires zero configuration&lt;/li&gt;
&lt;li&gt;It saves money (no RDS bills)&lt;/li&gt;
&lt;li&gt;It's easy to move or back up (just copy the file)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the time comes to scale, migration will be straightforward.&lt;br&gt;
Until then, SQLite lets me focus on what matters: building the product, not managing infrastructure.&lt;/p&gt;

&lt;p&gt;So before spinning up another managed Postgres instance, consider starting with SQLite. You might be surprised how far it takes you.&lt;/p&gt;

</description>
      <category>database</category>
      <category>sqlite</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>🚀 How I Deployed My Startup's Server Without Kubernetes or Docker (Yet)</title>
      <dc:creator>Sergey Podgorny</dc:creator>
      <pubDate>Tue, 07 Oct 2025 14:00:00 +0000</pubDate>
      <link>https://dev.to/kinsly/how-i-deployed-my-startups-server-without-kubernetes-or-docker-yet-14h5</link>
      <guid>https://dev.to/kinsly/how-i-deployed-my-startups-server-without-kubernetes-or-docker-yet-14h5</guid>
      <description>&lt;p&gt;Almost every article these days talks about setting up Kubernetes clusters, writing Terraform scripts, or diving deep into AWS-managed services. While those tools are powerful, I believe that for most small projects, they are unnecessary overhead.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;&lt;a href="https://getkinsly.com" rel="noopener noreferrer"&gt;Kinsly&lt;/a&gt;&lt;/strong&gt;, my still-small project, I chose a simpler and more old-school approach. It's lighter, uses less memory, has fewer layers of abstraction, and remains secure. In this post, I'll walk through how I deployed my server, why I avoided AWS/GCP at this stage, and the exact setup I used, from SSH security to deployment automation.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌍 Choosing the Right Infrastructure
&lt;/h2&gt;

&lt;p&gt;For years, I favored &lt;strong&gt;DigitalOcean&lt;/strong&gt;: simple, affordable, and reliable. But for this first phase, I went even more cost-effective and chose &lt;strong&gt;Contabo VPS&lt;/strong&gt;. Although their SLA looks low on paper, performance in practice has been surprisingly stable.&lt;/p&gt;

&lt;p&gt;Unlike AWS or GCP, where you can easily drown in unnecessary configuration, here I get just what I need: a straightforward server that I fully control. And importantly, I can move it anywhere at any time, without being locked into provider-specific services.&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;Lesson&lt;/strong&gt;: Keep deployments &lt;strong&gt;provider-agnostic&lt;/strong&gt; so you can move quickly when needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔐 Securing Access with SSH
&lt;/h2&gt;

&lt;p&gt;The first thing I did was lock down access. By default, servers allow SSH login with a password. That's a major attack vector: bots constantly attempt brute-force logins with common credentials. An SSH key is far more secure.&lt;/p&gt;

&lt;p&gt;Secure SSH key was created locally using this command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ssh-keygen -t ed25519 -f ~/.ssh/deploy_server.pem -C "deploy@gitlab"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then the local public key was uploaded to the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# This command is the easiest way to install your key.
$ ssh-copy-id -i ~/.ssh/deploy_server.pem deploy@[target-ip]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, I hardened the SSH configuration on the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo vim /etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside this file, I made sure the following lines were set. This completely disables password logins and ensures only key-based access is allowed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PermitRootLogin prohibit-password
# Disallow password authentication entirely.
PasswordAuthentication no
# Ensure public key authentication is enabled.
PubkeyAuthentication yes
# Also disable challenge-response auth, which can sometimes be a password fallback.
ChallengeResponseAuthentication no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, I applied the changes by restarting the SSH service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From now on, only valid SSH keys can connect. This instantly shuts down most automated attacks.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;IMPORTANT&lt;/strong&gt;: Before you close your current terminal session, open a new terminal and confirm you can still log in with your SSH key. If you don't, you could lock yourself out of your own server!&lt;/p&gt;




&lt;h2&gt;
  
  
  🌐 Cloudflare for Domains &amp;amp; Security
&lt;/h2&gt;

&lt;p&gt;I registered my domain through &lt;strong&gt;Cloudflare&lt;/strong&gt;. Unlike GoDaddy or Namecheap, Cloudflare sells domains at cost price (no markup) and forces you to use its DNS — this turned out to be a blessing. DNS hosting is free and comes with Cloudflare Proxy.&lt;/p&gt;

&lt;p&gt;With just the free plan, I got:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hidden server IP (Cloudflare Proxy)&lt;/li&gt;
&lt;li&gt;Free DDoS protection&lt;/li&gt;
&lt;li&gt;Global caching across regions for speed&lt;/li&gt;
&lt;li&gt;Basic analytics&lt;/li&gt;
&lt;li&gt;Built-in security filtering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvspmmmxa3246skb1fcgi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvspmmmxa3246skb1fcgi.png" alt="Cloudflare request analytics" width="800" height="726"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 In the past, I even paid AWS money just to register DNS records. Cloudflare gives more for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚔️ Hardening the Server Firewall
&lt;/h2&gt;

&lt;p&gt;As soon as I installed nginx, bots started probing for vulnerabilities (checking for &lt;code&gt;/wp-login.php&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt; folders, etc.). To prevent direct access to my VPS, I restricted requests to Cloudflare IPs only.&lt;/p&gt;

&lt;p&gt;To prevent this, &lt;strong&gt;&lt;code&gt;ufw&lt;/code&gt; (Uncomplicated Firewall) method is the best practice&lt;/strong&gt;. It provides the highest level of security and efficiency. It is a one-time setup, though you should plan to update the IP list once or twice a year, as Cloudflare occasionally adds new ranges.&lt;/p&gt;

&lt;p&gt;First, always allow SSH, or you'll lock yourself out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo ufw allow ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo ufw allow 22/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then allow traffic from all of Cloudflare's IPs on port 443 (HTTPS). You can get the latest lists from &lt;a href="https://cloudflare.com/ips" rel="noopener noreferrer"&gt;cloudflare.com/ips&lt;/a&gt;. You can run a simple loop to add all the current IPv4 and IPv6 addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# For IPv4
$ for ip in $(curl -s https://www.cloudflare.com/ips-v4); do sudo ufw allow from $ip to any port 443 proto tcp; done

# For IPv6
$ for ip in $(curl -s https://www.cloudflare.com/ips-v6); do sudo ufw allow from $ip to any port 443 proto tcp; done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After specifically allowing Cloudflare IPs, you can now safely deny all other traffic on ports 80 and 443.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo ufw deny http
$ sudo ufw deny https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forces all web traffic through Cloudflare's secure proxy, effectively locking the back door to the server.&lt;/p&gt;

&lt;p&gt;Then, if it's not already enabled, turn on the firewall. It will ask for confirmation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo ufw enable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify your rules are in place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a list showing that port 22 is allowed from anywhere, port 443 is allowed from Cloudflare's IPs, and ports 80/443 are denied from anywhere else.&lt;/p&gt;

&lt;p&gt;You can also do this within nginx itself, though it's slightly less efficient as nginx has to process the connection before denying it.&lt;/p&gt;

&lt;p&gt;You would create a file (&lt;code&gt;/etc/nginx/snippets/cloudflare-ips.conf&lt;/code&gt;) containing &lt;code&gt;allow&lt;/code&gt; rules for all of Cloudflare's IPs, and then include it in your server block, followed by &lt;code&gt;deny all;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# IPv4 Ranges
&lt;/span&gt;&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;173&lt;/span&gt;.&lt;span class="m"&gt;245&lt;/span&gt;.&lt;span class="m"&gt;48&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;20&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;103&lt;/span&gt;.&lt;span class="m"&gt;21&lt;/span&gt;.&lt;span class="m"&gt;244&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;22&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;103&lt;/span&gt;.&lt;span class="m"&gt;22&lt;/span&gt;.&lt;span class="m"&gt;200&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;22&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;103&lt;/span&gt;.&lt;span class="m"&gt;31&lt;/span&gt;.&lt;span class="m"&gt;4&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;22&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;141&lt;/span&gt;.&lt;span class="m"&gt;101&lt;/span&gt;.&lt;span class="m"&gt;64&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;18&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;108&lt;/span&gt;.&lt;span class="m"&gt;162&lt;/span&gt;.&lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;18&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;190&lt;/span&gt;.&lt;span class="m"&gt;93&lt;/span&gt;.&lt;span class="m"&gt;240&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;20&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;188&lt;/span&gt;.&lt;span class="m"&gt;114&lt;/span&gt;.&lt;span class="m"&gt;96&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;20&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;197&lt;/span&gt;.&lt;span class="m"&gt;234&lt;/span&gt;.&lt;span class="m"&gt;240&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;22&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;198&lt;/span&gt;.&lt;span class="m"&gt;41&lt;/span&gt;.&lt;span class="m"&gt;128&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;17&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;162&lt;/span&gt;.&lt;span class="m"&gt;158&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;15&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;104&lt;/span&gt;.&lt;span class="m"&gt;16&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;13&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;104&lt;/span&gt;.&lt;span class="m"&gt;24&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;14&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;172&lt;/span&gt;.&lt;span class="m"&gt;64&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;13&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;131&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;72&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;22&lt;/span&gt;;

&lt;span class="c"&gt;# IPv6 Ranges
&lt;/span&gt;&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2400&lt;/span&gt;:&lt;span class="n"&gt;cb00&lt;/span&gt;::/&lt;span class="m"&gt;32&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2606&lt;/span&gt;:&lt;span class="m"&gt;4700&lt;/span&gt;::/&lt;span class="m"&gt;32&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2803&lt;/span&gt;:&lt;span class="n"&gt;f800&lt;/span&gt;::/&lt;span class="m"&gt;32&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2405&lt;/span&gt;:&lt;span class="n"&gt;b500&lt;/span&gt;::/&lt;span class="m"&gt;32&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2405&lt;/span&gt;:&lt;span class="m"&gt;8100&lt;/span&gt;::/&lt;span class="m"&gt;32&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="n"&gt;c0f&lt;/span&gt;:&lt;span class="n"&gt;f248&lt;/span&gt;::/&lt;span class="m"&gt;32&lt;/span&gt;;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="n"&gt;a06&lt;/span&gt;:&lt;span class="m"&gt;98&lt;/span&gt;&lt;span class="n"&gt;c0&lt;/span&gt;::/&lt;span class="m"&gt;29&lt;/span&gt;;

&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This snippet will then need to be included separately in the corresponding server directive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
    &lt;span class="c"&gt;# ...
&lt;/span&gt;    &lt;span class="n"&gt;include&lt;/span&gt; /&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;nginx&lt;/span&gt;/&lt;span class="n"&gt;snippets&lt;/span&gt;/&lt;span class="n"&gt;cloudflare&lt;/span&gt;-&lt;span class="n"&gt;ips&lt;/span&gt;.&lt;span class="n"&gt;conf&lt;/span&gt;;
    &lt;span class="c"&gt;# ...
&lt;/span&gt;}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🏗️ A Simple, Minimal Stack
&lt;/h2&gt;

&lt;p&gt;My architecture is intentionally simple: instead of React or Angular SSR, the first version of Kinsly runs on a &lt;strong&gt;static HTML page&lt;/strong&gt;. Form submissions go to a small &lt;strong&gt;Go service&lt;/strong&gt; that listens only on the local loopback interface (&lt;code&gt;127.0.0.1&lt;/code&gt;) and it's &lt;em&gt;not exposed&lt;/em&gt; to the outside world. Nginx acts as a reverse proxy, forwarding public requests from &lt;code&gt;api.getkinsly.com&lt;/code&gt; to this private port. This is a secure and standard pattern.&lt;/p&gt;

&lt;p&gt;I also considered using Unix sockets, but TCP on localhost was good enough.&lt;/p&gt;

&lt;p&gt;Here's a look at the core of my Nginx config for the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/nginx/sites-available/api.getkinsly.com
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; {
    &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;.&lt;span class="n"&gt;getkinsly&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;;

    &lt;span class="n"&gt;listen&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt; &lt;span class="n"&gt;http2&lt;/span&gt;;
    &lt;span class="c"&gt;# ... (all my SSL and security headers go here) ...
&lt;/span&gt;
    &lt;span class="n"&gt;location&lt;/span&gt; / {
        &lt;span class="c"&gt;# These two lines enable efficient keep-alive connections to the backend.
&lt;/span&gt;        &lt;span class="n"&gt;proxy_http_version&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;;

        &lt;span class="c"&gt;# Pass essential client information to the Go application.
&lt;/span&gt;        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Real&lt;/span&gt;-&lt;span class="n"&gt;IP&lt;/span&gt; $&lt;span class="n"&gt;remote_addr&lt;/span&gt;;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;Proto&lt;/span&gt; $&lt;span class="n"&gt;scheme&lt;/span&gt;;

        &lt;span class="c"&gt;# The actual proxy pass to the local Go service.
&lt;/span&gt;        &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;:&lt;span class="m"&gt;8001&lt;/span&gt;;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For now, Kinsly just needs to collect form data. Deploying a full Postgres or MySQL server at this stage would be pure over-engineering, so I deliberately chose &lt;strong&gt;SQLite&lt;/strong&gt; - it's file-based, requires zero configuration, incredibly fast, and perfect for the simple task of collecting waiting list emails.&lt;/p&gt;

&lt;p&gt;Later, when scaling requires it, migrating to Postgres or MySQL will be straightforward.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚙️ Keeping the App Alive with systemd
&lt;/h2&gt;

&lt;p&gt;My Go application runs as a &lt;code&gt;systemd&lt;/code&gt; service, ensuring it's always on. The service is configured to run as the non-privileged &lt;code&gt;www-data&lt;/code&gt; user for security.&lt;/p&gt;

&lt;p&gt;Example service file (&lt;code&gt;/etc/systemd/system/kinsly-api.service&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;[Unit]
Description=Kinsly API Service
# Start this service only after the network is available.
After=network.target

[Service]
# The user and group the service will run as.
# Running as a dedicated, non-sudo user is a critical security best practice.
User=www-data
Group=www-data

# The command to start the application. Make sure the binary is executable (chmod +x binary)
ExecStart=/opt/kinsly/current/kinsly-api
Restart=always
# Wait 5 seconds before restarting to prevent rapid-fire restarts.
RestartSec=5

# Set environment variables if your application needs them.
Environment="APPLICATION_MODE=prod"

[Install]
# This allows the service to be enabled to start on boot.
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, if the app crashes or the server reboots, &lt;code&gt;systemd&lt;/code&gt; automatically restarts it.&lt;/p&gt;




&lt;h2&gt;
  
  
  👤 Creating a Safe Deploy User
&lt;/h2&gt;

&lt;p&gt;My GitLab CI/CD pipeline, however, connects as a separate &lt;code&gt;deploy&lt;/code&gt; user that does not have full &lt;code&gt;sudo&lt;/code&gt; access. This creates a problem: how does the CI/CD pipeline restart the service after a new deployment?&lt;/p&gt;

&lt;p&gt;The solution is to grant the &lt;code&gt;deploy&lt;/code&gt; user permission to run only that one specific command. This is done via the &lt;code&gt;sudoers&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;The ONLY safe way to edit the sudoers file is with &lt;code&gt;visudo&lt;/code&gt;. It performs a syntax check on save to prevent you from breaking &lt;code&gt;sudo&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo visudo -f /etc/sudoers.d/deploy-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added this single line to the bottom of the file. This allows the &lt;code&gt;deploy&lt;/code&gt; user to restart the &lt;code&gt;kinsly-api&lt;/code&gt; service without a password.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;deploy&lt;/span&gt; &lt;span class="n"&gt;ALL&lt;/span&gt;=(&lt;span class="n"&gt;ALL&lt;/span&gt;) &lt;span class="n"&gt;NOPASSWD&lt;/span&gt;: /&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;bin&lt;/span&gt;/&lt;span class="n"&gt;systemctl&lt;/span&gt; &lt;span class="n"&gt;restart&lt;/span&gt; &lt;span class="n"&gt;kinsly&lt;/span&gt;-&lt;span class="n"&gt;api&lt;/span&gt;.&lt;span class="n"&gt;service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a secure and granular way to enable deployment automation without giving away &lt;code&gt;root&lt;/code&gt; access. The CI/CD pipeline can now run &lt;code&gt;sudo systemctl restart kinsly-api.service&lt;/code&gt;, and it's the only &lt;code&gt;sudo&lt;/code&gt; command it's allowed to execute.&lt;/p&gt;

&lt;p&gt;ℹ️ Make sure the directive &lt;code&gt;@includedir /etc/sudoers.d&lt;/code&gt; is enabled in the main &lt;code&gt;/etc/sudoers&lt;/code&gt; configuration file. If it's not there, you should add it using &lt;code&gt;sudo visudo&lt;/code&gt; command (without &lt;code&gt;-f&lt;/code&gt; argument).&lt;/p&gt;

&lt;p&gt;Also, ensure correct file ownership &amp;amp; permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo chown root:root /etc/sudoers.d/deploy-app
$ sudo chmod 0440 /etc/sudoers.d/deploy-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate sudoers syntax, so you don't lock yourself out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo visudo -c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔄 Deployment via GitLab CI
&lt;/h2&gt;

&lt;p&gt;Deployment is automated:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitLab CI builds a Go binary.&lt;/li&gt;
&lt;li&gt;It packages the binary + static HTML into a &lt;code&gt;.tar.gz&lt;/code&gt; archive.&lt;/li&gt;
&lt;li&gt;The archive is uploaded to the server and unpacks under new release folder &lt;code&gt;/opt/kinsly/releases/&amp;lt;timestamp&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A symlink &lt;code&gt;current&lt;/code&gt; → &lt;code&gt;&amp;lt;latest_release&amp;gt;&lt;/code&gt; is updated.&lt;/li&gt;
&lt;li&gt;The service is restarted.&lt;/li&gt;
&lt;li&gt;Old releases are pruned (keep the last 5 for rollback).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is simple, reliable, and avoids the need for Docker at this stage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx99lh6mltjlj4gvhmk1s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx99lh6mltjlj4gvhmk1s.png" alt="GitLab pipelines triggered by tag creation" width="727" height="387"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🍪 Frontend Challenges: Analytics, Consent &amp;amp; Captcha
&lt;/h2&gt;

&lt;p&gt;Even with a simple site, there are complexities. To use &lt;strong&gt;Google Analytics&lt;/strong&gt;, I needed a cookie consent banner to comply with EU law. I chose &lt;strong&gt;axept.io&lt;/strong&gt;, a free and Google-certified service. &lt;/p&gt;

&lt;p&gt;I also added a free captcha (&lt;strong&gt;Altcha&lt;/strong&gt;) to prevent bots from spamming my form with fake emails. Spam protection was essential. Without it, malicious bots could flood forms, causing emails to be marked as spam.  While not perfect, it filters out the majority of malicious requests.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛡️ Final Security Checks
&lt;/h2&gt;

&lt;p&gt;Before going live, I checked open ports with &lt;code&gt;nmap&lt;/code&gt; and online scanners to ensure only nginx was exposed. My Go app, database, and system processes remain inaccessible from outside.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ nmap -sS -Pn -T5 -p- [target-ip]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmttu2yx5xy5i4e2u2tw7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmttu2yx5xy5i4e2u2tw7.png" alt="Scanned open ports on the server" width="800" height="188"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Conclusion
&lt;/h2&gt;

&lt;p&gt;This setup may sound "basic" compared to a Kubernetes cluster on AWS, but for an early-stage project, it's &lt;strong&gt;exactly what I need&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimal cost&lt;/li&gt;
&lt;li&gt;Maximum control&lt;/li&gt;
&lt;li&gt;Easy portability&lt;/li&gt;
&lt;li&gt;Strong enough security&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As the project grows, I will likely containerize services and introduce Docker orchestration, but for now, this lean approach lets me move fast without unnecessary complexity. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;It reminds me that sometimes the most elegant solution is the simplest one.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>server</category>
      <category>security</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
