<?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: Prince</title>
    <description>The latest articles on DEV Community by Prince (@offpage_prince).</description>
    <link>https://dev.to/offpage_prince</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%2F3904148%2F08be118a-2043-4f34-aeeb-decac6008f37.png</url>
      <title>DEV Community: Prince</title>
      <link>https://dev.to/offpage_prince</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/offpage_prince"/>
    <language>en</language>
    <item>
      <title>Counting "repeated questions" in exam PDFs: the rule that keeps the numbers honest</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:39:46 +0000</pubDate>
      <link>https://dev.to/offpage_prince/counting-repeated-questions-in-exam-pdfs-the-rule-that-keeps-the-numbers-honest-11bd</link>
      <guid>https://dev.to/offpage_prince/counting-repeated-questions-in-exam-pdfs-the-rule-that-keeps-the-numbers-honest-11bd</guid>
      <description>&lt;p&gt;When I tallied GTU (Gujarat Technological University) Physics papers for juniors, the hardest part was not the code. It was deciding what "repeated" means, so the numbers do not lie to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;22 official GTU Physics question papers, Summer 2022 to Summer 2026, across three subject codes (3110011, 3110018 and the new BE01000021), downloaded from GTU's own repository. Text came out with &lt;code&gt;pdftotext&lt;/code&gt;, then every sub-question, including the OR options, got a topic tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: count a topic once per paper
&lt;/h2&gt;

&lt;p&gt;If Ruby laser shows up in Q.3(c) and again in Q.5(a) OR, that is still one paper. Otherwise a single paper that loves lasers inflates the count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;papers_per_topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tagged&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# tagged: list of (paper_id, topic) for every sub-question
&lt;/span&gt;    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;paper_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paper_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;set&lt;/code&gt; does all the work. Topic count = number of distinct papers it appeared in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: deduplicate identical files
&lt;/h2&gt;

&lt;p&gt;Two BE01R00021 PDFs turned out to be byte-for-byte the same files as BE01000021 papers (the header literally says "BE01000021/BE01R00021"). Counting them twice would have doubled the evidence for free. A hash check catches this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unique_pdfs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So BE01000021 ended up with 4 papers, not 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: say when extraction failed
&lt;/h2&gt;

&lt;p&gt;A few lines in two 3110018 papers (Summer 2025 and Summer 2026) did not come out as text. Those two may be slightly under-counted, and the write-up says so instead of pretending.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the honest numbers showed
&lt;/h2&gt;

&lt;p&gt;For 3110018 (Group 2 Physics), solar cell or photovoltaic effect appeared in 9 of 9 papers. Kronig-Penney, hot point probe and four probe method each hit 7 of 9. Some questions repeated word for word, like "Kronig and Penney model explains origin of band gap" in W2022, S2024 and W2025.&lt;/p&gt;

&lt;p&gt;Past repetition is a pattern, not a promise. But with these three rules, at least the pattern is real.&lt;/p&gt;

&lt;p&gt;I put the papers on &lt;a href="https://gtuhero.com/gtu-papers/gujarat-technological-university-3110018-physics-winter-2025" rel="noopener noreferrer"&gt;GTU Hero&lt;/a&gt; if you want to check the Winter 2025 one yourself.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>beginners</category>
      <category>students</category>
    </item>
    <item>
      <title>Modelling GTU's 4-backlog detain rule in 15 lines of Python</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Sat, 31 Oct 2026 04:40:26 +0000</pubDate>
      <link>https://dev.to/offpage_prince/modelling-gtus-4-backlog-detain-rule-in-15-lines-of-python-32nc</link>
      <guid>https://dev.to/offpage_prince/modelling-gtus-4-backlog-detain-rule-in-15-lines-of-python-32nc</guid>
      <description>&lt;p&gt;I keep seeing GTU (Gujarat Technological University) juniors panic about the "4 backlog" rule, and half of them count it wrong. The rule has one twist that makes it a nice little exercise in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A backlog is a subject with an FF grade (below 35 marks). Missing the exam also counts as not cleared.&lt;/li&gt;
&lt;li&gt;At any time you can carry at most 4 pending backlogs. More than 4 and you can be detained for one year.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backlogs from the semester just before your current one are not counted.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the bug in most people's mental model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detention_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;pending maps semester -&amp;gt; number of FF subjects still not cleared.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;just_previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_sem&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;counted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;sem&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;just_previous&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;counted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;counted&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;


&lt;span class="c1"&gt;# Sem 4 student: 2 in Sem 1, 1 in Sem 2, 3 in Sem 3 (6 total)
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;detention_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;  &lt;span class="c1"&gt;# (3, False) -&amp;gt; safe
&lt;/span&gt;
&lt;span class="c1"&gt;# Same student moves to Sem 5 and clears nothing
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;detention_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;  &lt;span class="c1"&gt;# (6, True) -&amp;gt; detained
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same backlogs, different semester, opposite result. A backlog that is "free" today gets counted the moment you move up, so the real deadline for clearing a paper is one semester later than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the function does not cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Students admitted from 2018-19 onward follow a stricter table from GTU circular 5440 (19 July 2018), where certain semesters must be fully cleared before you enter the semester two years ahead.&lt;/li&gt;
&lt;li&gt;Attendance: you need at least 75% to fill the exam form at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So treat this as a sanity check, not a verdict. Your college exam cell has the final word for your batch.&lt;/p&gt;

&lt;p&gt;The full write-up, with the 2018 table, fees and a semester-by-semester plan, is on &lt;a href="https://gtuhero.com/blogs/gtu-backlog-rules-atkt-kt-detain-fees" rel="noopener noreferrer"&gt;GTU Hero&lt;/a&gt;. Disclosure: I build GTU Hero.&lt;/p&gt;

</description>
      <category>python</category>
      <category>beginners</category>
      <category>students</category>
      <category>india</category>
    </item>
    <item>
      <title>A 4-hour GTU syllabus unit showed up in all 9 Maths 1 papers. Here is the data</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Thu, 29 Oct 2026 04:38:36 +0000</pubDate>
      <link>https://dev.to/offpage_prince/a-4-hour-gtu-syllabus-unit-showed-up-in-all-9-maths-1-papers-here-is-the-data-5785</link>
      <guid>https://dev.to/offpage_prince/a-4-hour-gtu-syllabus-unit-showed-up-in-all-9-maths-1-papers-here-is-the-data-5785</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I built GTU Hero, a GTU previous-papers site. This count was done on GTU's own question paper repository and is not tied to anything you need to buy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Bhai, every GTU first-year student has heard "Fourier toh chhota chapter hai, skip kar". So I checked. I took the 9 official Mathematics-1 papers for subject code 3110014 (Summer 2022 to Summer 2026) from GTU's own repository (&lt;code&gt;gtu.ac.in/Download1.aspx&lt;/code&gt;) and counted every topic, one count per paper, OR options included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Syllabus hours vs exam reality
&lt;/h2&gt;

&lt;p&gt;In the official 3110014 syllabus, Unit 3 (Fourier series and half range expansions) is &lt;strong&gt;4 hours and 10% weightage&lt;/strong&gt;. The smallest unit on paper.&lt;/p&gt;

&lt;p&gt;It appeared in &lt;strong&gt;all 9 papers&lt;/strong&gt;, usually as a 7 marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven topics that appeared in all 9 papers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Papers (of 9)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fourier series (full or half range)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local extreme values of f(x, y)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change of order of integration&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solving a linear system (Gauss elimination or Gauss-Jordan)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eigenvalues and eigenvectors&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Taylor or Maclaurin series, or approximation&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L'Hospital and indeterminate forms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;9/9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Same Fourier sum, different year
&lt;/h2&gt;

&lt;p&gt;Not "similar type". Same question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fourier series of x² on (−π, π): S2022 Q.2(c), S2025 Q.1(c), S2026 Q.2(c) OR&lt;/li&gt;
&lt;li&gt;Fourier series of (π − x)/2 on (0, 2π), deduce π/4 = 1 − 1/3 + 1/5 − ...: S2024 Q.3(c) OR, S2026 Q.2(c)&lt;/li&gt;
&lt;li&gt;Fourier series of x + |x| on (−π, π): W2022 Q.2(c), W2024 Q.2(c) OR&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One thing that almost broke the count
&lt;/h2&gt;

&lt;p&gt;The Winter 2025 PDF gave only 587 characters from &lt;code&gt;pdftotext&lt;/code&gt;, which is just the header. The questions were images. So I read those pages as images instead. If you ever count papers yourself, check text length per file, otherwise a paper silently counts as "no questions".&lt;/p&gt;

&lt;h2&gt;
  
  
  Important: check your subject code
&lt;/h2&gt;

&lt;p&gt;The newer codes BE01000041 and BE01R00041 do &lt;strong&gt;not&lt;/strong&gt; have Fourier series or the matrix unit in their official syllabus. So this whole post is for 3110014 students. New-code students, ignore the Fourier and eigenvalue part.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use this
&lt;/h2&gt;

&lt;p&gt;Do the 9/9 topics first, then the rest of the syllabus. Past repetition is a pattern, not a promise, so do not skip units. If you want to see what a recent paper looks like, here is &lt;a href="https://gtuhero.com/gtu-papers/gujarat-technological-university-3110014-mathematics-1-winter-2025" rel="noopener noreferrer"&gt;the Winter 2025 Maths 1 paper on GTU Hero&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All the best for exams. Fourier ko halke mein mat lena.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>learning</category>
      <category>python</category>
      <category>discuss</category>
    </item>
    <item>
      <title>How I count repeated questions across 44 GTU exam papers (and what broke)</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Wed, 28 Oct 2026 04:40:58 +0000</pubDate>
      <link>https://dev.to/offpage_prince/how-i-count-repeated-questions-across-44-gtu-exam-papers-and-what-broke-439o</link>
      <guid>https://dev.to/offpage_prince/how-i-count-repeated-questions-across-44-gtu-exam-papers-and-what-broke-439o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I built GTU Hero, a site that shows GTU previous-year papers with repeated questions highlighted. This post is about the counting exercise behind it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Students at Gujarat Technological University (GTU) love "IMP question" lists. Google autocomplete suggested phrases like &lt;code&gt;gtu imp question bank sem 2&lt;/code&gt; when I checked in October 2026. The problem with most of those lists is that nobody says where they came from.&lt;/p&gt;

&lt;p&gt;So I tried a boring question first: if you take the official papers for one subject and just count, what actually repeats? No ML, no scraping tricks. A folder of PDFs, a spreadsheet and some Python. This post is the build story, including the parts that broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dataset
&lt;/h2&gt;

&lt;p&gt;I counted &lt;strong&gt;44 official papers&lt;/strong&gt; across three first-year subjects:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subject&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Papers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mathematics-2&lt;/td&gt;
&lt;td&gt;3110015&lt;/td&gt;
&lt;td&gt;9 (Summer 2022 to Summer 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mathematics-1&lt;/td&gt;
&lt;td&gt;3110014&lt;/td&gt;
&lt;td&gt;9 (same sessions)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mathematics-1&lt;/td&gt;
&lt;td&gt;BE01000041&lt;/td&gt;
&lt;td&gt;4 (Winter 2024 to Summer 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physics&lt;/td&gt;
&lt;td&gt;3110011&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physics&lt;/td&gt;
&lt;td&gt;3110018&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physics&lt;/td&gt;
&lt;td&gt;BE01000021&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every PDF came from GTU's own question paper repository (&lt;code&gt;gtu.ac.in/Download1.aspx&lt;/code&gt;: pick a session, pick BE, search the subject code). No third-party uploads, so anyone can rebuild the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: get text out of the PDFs
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;pdftotext&lt;/code&gt; on every file and checked the output length. Most papers gave somewhere between 3,000 and 8,000 characters. Then there was the Winter 2025 paper for 3110014. The PDF is about 195 KB, but &lt;code&gt;pdftotext&lt;/code&gt; returned 587 characters, which is just the header: subject code, date, time, total marks. The questions were images.&lt;/p&gt;

&lt;p&gt;That is the first lesson: &lt;strong&gt;a length check per file catches silent extraction failures.&lt;/strong&gt; If I had only looked at the combined output, I would have counted that paper as "has no questions". For the image-based papers I read the pages as images instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: decide what a "topic" is
&lt;/h2&gt;

&lt;p&gt;This is the part no library does for you. I tagged every sub-question, OR options included, with a topic like "Green's theorem" or "Ruby laser". The rule that kept the counts honest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A topic counts &lt;strong&gt;once per paper&lt;/strong&gt; if it appears anywhere in that paper, compulsory question or OR option.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that rule, a paper with Green's theorem in both Q.1 and an OR option would count twice and inflate the repeat rate.&lt;/p&gt;

&lt;p&gt;Judgement calls still happen. One Summer 2025 Mathematics-2 question solves an initial value problem without the word "Laplace" in it, but it sits in the Laplace question, so I counted it as Laplace. Calls like that are noted in the data so someone else can disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a long CSV, not a wide one
&lt;/h2&gt;

&lt;p&gt;My first version was a wide sheet: one row per topic, one column per session. It is nice to read and annoying to validate. The shipped version is long: one row per topic per paper, with a 0 or 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject_code,subject,topic,unit_or_module,session,appeared,position
3110015,Mathematics-2,Green's theorem (evaluate/verify),,S2022,1,Q2c-OR
3110015,Mathematics-2,Green's theorem (evaluate/verify),,S2023,1,Q1c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long data means the summary can be recomputed and checked against the hand-built one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/topic_by_paper.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appeared&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;kv&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3110015&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing clever. My rebuild script does the same thing and fails loudly if a count in the summary file does not match the per-paper rows, so the two files cannot drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the counts showed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mathematics-2 (3110015), 9 papers.&lt;/strong&gt; I tagged 22 topics. Nine appeared in all 9 papers: Green's theorem, div/curl checks, Fourier integral, a Laplace initial value problem, variation of parameters, undetermined coefficients, Euler-Cauchy equations, equations not of first degree (p and Clairaut), and power series solutions. Green's theorem sat in the compulsory Q.1(c) in 8 of the 9 papers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Syllabus hours are not exam frequency.&lt;/strong&gt; The official syllabus gives the Fourier integral unit 2 teaching hours and lists no separate weightage for it. It still appeared in all 9 papers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mathematics-1 (3110014).&lt;/strong&gt; 7 of 23 tagged topics appeared in every one of the 9 papers, including Fourier series, local extreme values of f(x, y) and change of order of integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The newer code is more uniform.&lt;/strong&gt; For BE01000041, 15 of 19 tagged topics appeared in all 4 papers. With only 4 papers that is an early signal, not a law.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Physics.&lt;/strong&gt; For 3110011, four topics were in all 9 papers: Ruby laser, torsion, NDT and a superconductor critical field numerical. For 3110018, solar cell or photovoltaic effect was the only topic in all 9.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact repeats exist.&lt;/strong&gt; I logged 38 questions that came back copy-paste level in two or more sessions. One Physics numerical (ultrasonic source at 0.09 MHz, pulse returning after 0.55 s) shows up in two 3110011 papers and two BE01000021 papers. That one jumped across subject codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it gets messy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One subject name, several codes.&lt;/strong&gt; GTU has more than one "Mathematics-2". 3110015 covers vector calculus, Laplace and series solutions. BE02000011 covers matrices, ODEs and complex variables. Tallies do not transfer between codes, so my data stores the code on every row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same PDF under two codes.&lt;/strong&gt; Where one file was served under two codes, I counted it once, otherwise the numbers double.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Possible under-counting.&lt;/strong&gt; A few lines in the 3110018 Summer 2025 and Summer 2026 PDFs did not extract as text, so those two papers may be slightly under-counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small samples.&lt;/strong&gt; Four papers is not a pattern yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Past repetition is not a promise.&lt;/strong&gt; GTU can change a paper any time. Use counts to decide the order of study, not what to skip.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;You do not need my files to repeat this. Download one subject's papers from the GTU repository, tag each sub-question with a topic, keep one row per topic per paper, and count. It takes an evening per subject and you will know your paper better than any IMP list.&lt;/p&gt;

&lt;p&gt;If you are a GTU student and want the plain-language version of the Mathematics-2 count, with the exam pattern and a study plan, it is here: &lt;a href="https://gtuhero.com/blogs/gtu-maths-2-papers-pyqs-repeated-sums" rel="noopener noreferrer"&gt;GTU Maths 2 Paper: PYQs, Old Papers and Repeated Sums&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have counted exam questions before, how did you handle topics that straddle two chapters? I tagged by the main method the question asks for, and I am not sure that is the best rule.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Writing note: I used an AI assistant to help tidy the draft. The counting, the numbers and the judgement calls are mine and were checked against the papers.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>beginners</category>
      <category>education</category>
    </item>
    <item>
      <title>How to Set an Hourly Rate That Actually Covers Your Costs</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:05:23 +0000</pubDate>
      <link>https://dev.to/offpage_prince/how-to-set-an-hourly-rate-that-actually-covers-your-costs-11e1</link>
      <guid>https://dev.to/offpage_prince/how-to-set-an-hourly-rate-that-actually-covers-your-costs-11e1</guid>
      <description>&lt;p&gt;Many freelancers and small agencies pick an hourly rate by looking at what others charge. That feels safe, but it ignores the one thing that matters most: your own costs and the hours you can realistically bill. A rate that works for someone else can quietly lose you money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with your target income
&lt;/h2&gt;

&lt;p&gt;Decide what you need to earn in a year before taxes. Include your salary, savings, and a buffer for slow months. This number is the foundation for everything else, so be honest with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add your business costs
&lt;/h2&gt;

&lt;p&gt;Next, list the costs of running the business for a year. Software, hardware, internet, insurance, accounting, training, and marketing all belong here. Freelancers often forget the tools they already pay for monthly, so check your bank statements. Add this total to your target income.&lt;/p&gt;

&lt;h2&gt;
  
  
  Estimate billable hours honestly
&lt;/h2&gt;

&lt;p&gt;This is where most rates go wrong. A year has about 2,080 working hours if you work 40 hours a week, but you will not bill all of them. Holidays, sick days, sales calls, admin, and learning all take time. Many independent professionals bill between 50 and 70 percent of their working hours.&lt;/p&gt;

&lt;p&gt;If you are not sure what your real ratio is, track your time for a month. Log both client work and internal work. The numbers usually surprise people, and they give you a solid base for pricing instead of a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do the math
&lt;/h2&gt;

&lt;p&gt;Divide your target income plus costs by your realistic billable hours. For example, if you need 60,000 in income, have 10,000 in costs, and expect to bill 1,200 hours a year, your minimum rate is about 58 per hour. Round up and add a margin for profit and risk.&lt;/p&gt;

&lt;p&gt;If you want to skip the arithmetic, our guide to &lt;a href="https://tracko.work/time-tracking-software-for-consultants" rel="noopener noreferrer"&gt;Time Tracking Software for Consultants&lt;/a&gt; shows how to log billable hours and see how changes affect the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the rate against the market
&lt;/h2&gt;

&lt;p&gt;Once you know your floor, compare it with market rates for your skills and region. If your floor is above the market, look for ways to raise your value, narrow your niche, or reduce costs. If it is below the market, you have room to charge more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revisit the rate every six months
&lt;/h2&gt;

&lt;p&gt;Costs change, skills improve, and client demand shifts. Set a reminder to review your rate twice a year. Use the time data you have collected to see whether your billable ratio has changed. If admin work keeps growing, that is a sign to automate or delegate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep tracking after you set the rate
&lt;/h2&gt;

&lt;p&gt;A good rate only holds up if you know where your hours go. Tracking time against projects and tasks shows which clients are profitable and which ones take more effort than they pay for. It also makes invoicing faster and reduces disputes, because you can show exactly what was done.&lt;/p&gt;

&lt;p&gt;Tools like Tracko make this simple. You track time against tasks with one click or automatically through the desktop app, and review reports by client and project. Over time, that data turns pricing from a guess into a decision.&lt;/p&gt;

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

&lt;p&gt;Your hourly rate should be built from your own numbers, not borrowed from someone else. Start with your income goal, add your costs, divide by honest billable hours, and check the result against the market. Then keep tracking, keep reviewing, and adjust as your business grows. Learn more at &lt;a href="https://tracko.work" rel="noopener noreferrer"&gt;Tracko&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Deploying FastAPI on a VPS with Docker Compose: The Production Checklist</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Mon, 28 Sep 2026 11:13:03 +0000</pubDate>
      <link>https://dev.to/offpage_prince/deploying-fastapi-on-a-vps-with-docker-compose-the-production-checklist-2edi</link>
      <guid>https://dev.to/offpage_prince/deploying-fastapi-on-a-vps-with-docker-compose-the-production-checklist-2edi</guid>
      <description>&lt;p&gt;Getting FastAPI running in Docker takes five minutes. Getting it to behave well on a real server takes a little longer, because the things that bite you in production rarely show up in the "hello world" tutorial: client IPs that all look the same, migrations racing the app on startup, containers running as root, and logs slowly filling the disk.&lt;/p&gt;

&lt;p&gt;This post is the checklist I work through when moving a FastAPI service from a laptop to a single Linux VPS with Docker Compose. It assumes you already have a working app and a server with Docker installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A Dockerfile that is boring on purpose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.13-slim&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 \&lt;/span&gt;
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app ./app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; alembic.ini .&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; migrations ./migrations&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 10001 appuser
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; appuser&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \&lt;/span&gt;
     "--workers", "2", "--forwarded-allow-ips", "*"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy &lt;code&gt;requirements.txt&lt;/code&gt; first.&lt;/strong&gt; Dependency installation is cached until that file changes, so code-only changes rebuild in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin versions&lt;/strong&gt; in &lt;code&gt;requirements.txt&lt;/code&gt; (&lt;code&gt;fastapi==0.115.6&lt;/code&gt;, not &lt;code&gt;fastapi&lt;/code&gt;). An unpinned rebuild months later is a different application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run as a non-root user.&lt;/strong&gt; If something in your dependency tree is ever exploited, the attacker lands as an unprivileged user inside the container instead of root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the exec form of &lt;code&gt;CMD&lt;/code&gt;.&lt;/strong&gt; With the JSON array form, Uvicorn is PID 1 and receives &lt;code&gt;SIGTERM&lt;/code&gt; directly, so in-flight requests finish during &lt;code&gt;docker compose down&lt;/code&gt; or a redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PYTHONUNBUFFERED=1&lt;/code&gt;&lt;/strong&gt; makes log lines appear immediately in &lt;code&gt;docker logs&lt;/code&gt; instead of arriving in bursts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On workers: recent Uvicorn versions can supervise several worker processes themselves with &lt;code&gt;--workers&lt;/code&gt;, so a separate Gunicorn layer is no longer required for most apps. Start with one or two workers per vCPU and measure. If your endpoints are mostly async and I/O bound, fewer workers than you expect often perform fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Trust the proxy, and only the proxy
&lt;/h2&gt;

&lt;p&gt;Your app will sit behind a reverse proxy that terminates HTTPS. Without extra configuration, FastAPI sees every request as plain HTTP coming from the proxy container's IP. That breaks redirects (they point to &lt;code&gt;http://&lt;/code&gt;), rate limiting, and any audit logging of client IPs.&lt;/p&gt;

&lt;p&gt;Uvicorn reads &lt;code&gt;X-Forwarded-For&lt;/code&gt; and &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; headers, but only from addresses listed in &lt;code&gt;--forwarded-allow-ips&lt;/code&gt;. Setting it to &lt;code&gt;*&lt;/code&gt; is acceptable &lt;strong&gt;only because the API container publishes no ports&lt;/strong&gt;: the proxy is the only thing that can reach it. If you ever expose port 8000 directly, tighten this to the proxy's network range.&lt;/p&gt;

&lt;p&gt;If you mount the API under a path prefix such as &lt;code&gt;/api&lt;/code&gt;, also set &lt;code&gt;root_path&lt;/code&gt; so the generated OpenAPI docs use the correct URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Compose file with health checks and a migration step
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16.4-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD:?set in .env}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/api:${API_TAG:?set in .env}&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alembic"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upgrade"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;head"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;

  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/api:${API_TAG}&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request.urlopen('http://127.0.0.1:8000/health',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;timeout=3)"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-file&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postgres is actually ready&lt;/strong&gt; before anything connects, not merely "started". &lt;code&gt;depends_on&lt;/code&gt; without a condition only waits for the container to exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations run exactly once per deploy&lt;/strong&gt;, in their own short-lived container, before the API starts. If a migration fails, the API does not start on a half-migrated schema, and &lt;code&gt;docker compose logs migrate&lt;/code&gt; tells you why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The health check uses Python itself&lt;/strong&gt;, so you do not need to install &lt;code&gt;curl&lt;/code&gt; in a slim image just to probe &lt;code&gt;/health&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log rotation&lt;/strong&gt; caps each container at about 30 MB of logs. The default JSON log driver has no limit, and "disk full" is one of the most common ways a small VPS falls over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your &lt;code&gt;/health&lt;/code&gt; endpoint should be cheap. A good pattern is a liveness check that returns immediately, plus an optional readiness check that runs &lt;code&gt;SELECT 1&lt;/code&gt; against the database with a short timeout.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Caddyfile&lt;/code&gt; for the API is two lines, and it handles certificates and HTTP to HTTPS redirects automatically:&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;api.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Configuration through the environment
&lt;/h2&gt;

&lt;p&gt;Keep secrets and per-environment values in &lt;code&gt;.env&lt;/code&gt; on the server (never in Git) and read them with &lt;code&gt;pydantic-settings&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseSettings&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;database_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;secret_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;cors_origins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app fails fast at startup if &lt;code&gt;DATABASE_URL&lt;/code&gt; is missing, which is exactly when you want to find out. Note the host in the URL is the Compose service name: &lt;code&gt;postgresql+asyncpg://app:...@db:5432/app&lt;/code&gt;. Using &lt;code&gt;localhost&lt;/code&gt; here is the classic cause of "connection refused", because inside the API container &lt;code&gt;localhost&lt;/code&gt; is the API container.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Deploys you can roll back
&lt;/h2&gt;

&lt;p&gt;Build images in CI, tag them with the Git commit SHA, and push them to a registry. On the server, a deploy becomes:&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;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/^API_TAG=.*/API_TAG=3f9c2ab/"&lt;/span&gt; .env
docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rolling back is the same three commands with the previous SHA. Avoid the &lt;code&gt;latest&lt;/code&gt; tag in production: when something breaks, you want to know exactly which build is running. Be careful with migrations here, too. A rollback of code is easy; a rollback of a destructive schema change is not, so prefer additive migrations (add a column, backfill, remove the old one in a later release).&lt;/p&gt;

&lt;p&gt;For a longer walkthrough that also covers first-time server preparation, domain setup and HTTPS from scratch, this &lt;a href="https://peon.sh/blogs/deploy-fastapi-vps-docker" rel="noopener noreferrer"&gt;step-by-step guide to deploying FastAPI on a VPS with Docker&lt;/a&gt; is a good companion to the checklist here.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Back up the database before you need to
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;pgdata&lt;/code&gt; volume is the only irreplaceable thing on this server. Containers and images can be rebuilt in minutes; data cannot. At minimum, run a nightly &lt;code&gt;pg_dump&lt;/code&gt; to storage that lives somewhere other than the VPS, keep a few weeks of history, and actually test a restore once. If you want a concrete setup with scheduling, retention and encryption, see this walkthrough on &lt;a href="https://peon.sh/blogs/postgres-backup-s3" rel="noopener noreferrer"&gt;automating Postgres backups to S3&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pin versions, run as non-root, use exec-form &lt;code&gt;CMD&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Publish no app ports; let the reverse proxy be the only entry point, and configure forwarded headers.&lt;/li&gt;
&lt;li&gt;Gate startup on a healthy database and a successful migration container.&lt;/li&gt;
&lt;li&gt;Rotate logs so they cannot fill the disk.&lt;/li&gt;
&lt;li&gt;Tag images by commit so deploys and rollbacks are one command.&lt;/li&gt;
&lt;li&gt;Back up Postgres off the server and test the restore.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic, but together it is the difference between an API that runs and an API you can leave alone for months.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Traefik vs Caddy on One Docker Host: Same Three Apps, Two Configs</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:55:48 +0000</pubDate>
      <link>https://dev.to/offpage_prince/traefik-vs-caddy-on-one-docker-host-same-three-apps-two-configs-3afd</link>
      <guid>https://dev.to/offpage_prince/traefik-vs-caddy-on-one-docker-host-same-three-apps-two-configs-3afd</guid>
      <description>&lt;p&gt;Most "Traefik vs Caddy" debates stay abstract: one is "dynamic", the other is "simple". That is true, but it does not help much when you are staring at a fresh VPS with three containers that all want ports 80 and 443.&lt;/p&gt;

&lt;p&gt;So here is a more practical test. Take one small but realistic stack and wire it up twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app.example.com&lt;/code&gt;: a web frontend listening on port 3000&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api.example.com&lt;/code&gt;: an API listening on port 8000, with a &lt;code&gt;/health&lt;/code&gt; endpoint&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;admin.example.com&lt;/code&gt;: an internal tool that should sit behind basic auth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Requirements for both versions: automatic HTTPS, HTTP to HTTPS redirects, no application ports published on the host, and a health check on the API. Then we compare what it felt like.&lt;/p&gt;

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

&lt;p&gt;Both setups use one Docker network that the proxy and the apps share. The apps never publish ports; only the proxy binds to the host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DNS for all three hostnames points at the server's public IP, and ports 80 and 443 are open in the firewall. Both proxies need port 80 reachable for the HTTP-01 ACME challenge unless you switch to DNS challenges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: Traefik
&lt;/h2&gt;

&lt;p&gt;Traefik's model is "configure the proxy once, then describe routes on each container with labels". The static configuration goes on the Traefik container itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v3&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--providers.docker=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--providers.docker.exposedbydefault=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--providers.docker.network=web&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.web.address=:80&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.websecure.address=:443&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.web.http.redirections.entrypoint.to=websecure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.web.http.redirections.entrypoint.scheme=https&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificatesresolvers.le.acme.email=ops@example.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificatesresolvers.le.acme.httpchallenge.entrypoint=web&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./letsencrypt:/letsencrypt&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then each service declares its own routing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/frontend:1.4.2&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.enable=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.app.rule=Host(`app.example.com`)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.app.entrypoints=websecure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.app.tls.certresolver=le&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.app.loadbalancer.server.port=3000&lt;/span&gt;

  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/api:2.0.1&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.enable=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.api.rule=Host(`api.example.com`)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.api.entrypoints=websecure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.api.tls.certresolver=le&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.api.loadbalancer.server.port=8000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.api.loadbalancer.healthcheck.path=/health&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.api.loadbalancer.healthcheck.interval=10s&lt;/span&gt;

  &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/admin:0.9.0&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.enable=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.admin.rule=Host(`admin.example.com`)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.admin.entrypoints=websecure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.admin.tls.certresolver=le&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.admin.middlewares=admin-auth&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.middlewares.admin-auth.basicauth.users=admin:$$apr1$$REPLACE$$WITHHASH&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.admin.loadbalancer.server.port=8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the basic auth hash with &lt;code&gt;htpasswd -nb admin 'your-password'&lt;/code&gt; and double every &lt;code&gt;$&lt;/code&gt; so Compose does not treat it as variable interpolation. That doubling trips up almost everyone once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stood out:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding a fourth app never touches the proxy. You add labels, run &lt;code&gt;docker compose up -d&lt;/code&gt;, and Traefik picks up the route within seconds.&lt;/li&gt;
&lt;li&gt;The labels are verbose. Router, service and middleware names must line up exactly, and a typo fails quietly: the route simply does not exist and you get a 404.&lt;/li&gt;
&lt;li&gt;Mounting the Docker socket gives Traefik broad control over the Docker daemon. On anything shared or important, put a socket proxy such as &lt;code&gt;tecnativa/docker-socket-proxy&lt;/code&gt; in between and expose only read access to containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Version 2: Caddy
&lt;/h2&gt;

&lt;p&gt;Caddy's model is "one readable file describes every site". The container is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443/udp"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_config:/config&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app services keep their images and the &lt;code&gt;web&lt;/code&gt; network, but lose all labels. The routing lives in the &lt;code&gt;Caddyfile&lt;/code&gt;:&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;app.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;api.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;health_uri&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt;
        &lt;span class="s"&gt;health_interval&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;admin.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;basic_auth&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;admin&lt;/span&gt; &lt;span class="nv"&gt;$2a$14$REPLACE_WITH_BCRYPT_HASH&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="s"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the hash with &lt;code&gt;docker compose exec caddy caddy hash-password&lt;/code&gt;. HTTPS, certificate renewal, and the HTTP to HTTPS redirect all happen without a single extra line. The published &lt;code&gt;443/udp&lt;/code&gt; port lets Caddy serve HTTP/3, which it enables by default.&lt;/p&gt;

&lt;p&gt;After editing the file, reload without downtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /etc/caddy caddy caddy reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What stood out:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The whole routing table fits on one screen. A new teammate can read it and understand the server in a minute.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/data&lt;/code&gt; volume matters. Lose it and Caddy requests fresh certificates on every restart, which can run you into Let's Encrypt rate limits.&lt;/li&gt;
&lt;li&gt;Adding an app means editing a central file and reloading. That is a feature for small, stable servers and friction for servers where containers come and go all day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Traefik&lt;/th&gt;
&lt;th&gt;Caddy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where routes live&lt;/td&gt;
&lt;td&gt;Labels on each container&lt;/td&gt;
&lt;td&gt;One Caddyfile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New app workflow&lt;/td&gt;
&lt;td&gt;Add labels, &lt;code&gt;up -d&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Edit file, reload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS defaults&lt;/td&gt;
&lt;td&gt;Needs resolver + entrypoint config&lt;/td&gt;
&lt;td&gt;On by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging a missing route&lt;/td&gt;
&lt;td&gt;Dashboard or logs; typos fail silently&lt;/td&gt;
&lt;td&gt;Config errors fail loudly on reload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker socket access&lt;/td&gt;
&lt;td&gt;Required for discovery&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic environments (Swarm, many short-lived stacks)&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Possible via the caddy-docker-proxy plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How I would choose
&lt;/h2&gt;

&lt;p&gt;Pick &lt;strong&gt;Caddy&lt;/strong&gt; when you have a handful of long-lived services, one or two people managing the server, and you value being able to read the entire configuration at a glance. For most single-VPS setups this is the calmer option, and its error messages are friendlier when something goes wrong.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Traefik&lt;/strong&gt; when services appear and disappear often, when several people deploy independently and should not need to edit a shared file, or when you are already on Swarm or planning to move towards an orchestrator. The label model scales with the number of teams rather than the number of config files.&lt;/p&gt;

&lt;p&gt;If you want a broader breakdown that also covers load balancing strategies, middleware, and performance considerations, this &lt;a href="https://peon.sh/blogs/traefik-vs-caddy" rel="noopener noreferrer"&gt;deeper Traefik vs Caddy comparison for Docker&lt;/a&gt; goes further than the hands-on test above. And if your real question is less "which proxy" and more "how do I lay out a server that hosts many projects cleanly", this guide on &lt;a href="https://peon.sh/blogs/how-to-run-multiple-apps-on-one-vps" rel="noopener noreferrer"&gt;running multiple apps on one VPS&lt;/a&gt; covers domains, networks, and resource limits around the proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few gotchas that apply to both
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not publish app ports.&lt;/strong&gt; If &lt;code&gt;api&lt;/code&gt; still has &lt;code&gt;ports: ["8000:8000"]&lt;/code&gt;, it is reachable over plain HTTP on the server IP, bypassing your auth and TLS. Remove it and let the proxy be the only door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use service names, not localhost.&lt;/strong&gt; Inside the proxy container, &lt;code&gt;localhost&lt;/code&gt; is the proxy itself. Always target &lt;code&gt;api:8000&lt;/code&gt;, never &lt;code&gt;127.0.0.1:8000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass real client IPs.&lt;/strong&gt; Both proxies set &lt;code&gt;X-Forwarded-For&lt;/code&gt;, but your app framework must be told to trust the proxy. Otherwise rate limiting and logs see every request as coming from the proxy container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up certificate storage.&lt;/strong&gt; That means &lt;code&gt;acme.json&lt;/code&gt; for Traefik and the &lt;code&gt;/data&lt;/code&gt; volume for Caddy. Both are small and save you from rate limit trouble after a rebuild.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either proxy will serve you well. The better question is where you want your routing to live: next to each service, or in one file you can read top to bottom. If you would rather not maintain that layer at all, self-hosted platforms like Peon, Coolify and Dokploy generate the proxy configuration for you, but knowing what they do underneath makes debugging much easier when a certificate refuses to issue at 2 a.m.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>selfhosted</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker Compose in Production on a Single VPS: A Practical Checklist</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:51:13 +0000</pubDate>
      <link>https://dev.to/offpage_prince/docker-compose-in-production-on-a-single-vps-a-practical-checklist-422c</link>
      <guid>https://dev.to/offpage_prince/docker-compose-in-production-on-a-single-vps-a-practical-checklist-422c</guid>
      <description>&lt;p&gt;Kubernetes gets most of the attention, but a large share of real production workloads run on one or two virtual machines with Docker Compose. For a SaaS in its first years, an internal tool, or a content site, a single well configured VPS is cheaper, easier to reason about, and fast to recover.&lt;/p&gt;

&lt;p&gt;The catch is that the Compose file that works on a laptop is not the Compose file you want in production. This checklist covers the changes that matter most, in roughly the order I apply them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Pin image versions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;image: postgres:latest&lt;/code&gt; is a time bomb. The next &lt;code&gt;docker compose pull&lt;/code&gt; can bring a new major version and a data format your volume does not support.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16.4-alpine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin to a specific minor or patch version, and upgrade deliberately. For your own images, tag with the Git SHA or a release version rather than &lt;code&gt;latest&lt;/code&gt;, so you always know what is running and can roll back to a known tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Always set a restart policy
&lt;/h2&gt;

&lt;p&gt;Containers crash, and hosts reboot for kernel updates. Without a restart policy, a reboot means downtime until someone notices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unless-stopped&lt;/code&gt; survives reboots but respects a manual &lt;code&gt;docker compose stop&lt;/code&gt;, which is usually what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Add health checks and use them
&lt;/h2&gt;

&lt;p&gt;A running container is not the same as a healthy service. Health checks let Docker know when your app is actually ready, and &lt;code&gt;depends_on&lt;/code&gt; can wait for them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-qO-"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16.4-alpine&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the health endpoint cheap. It should confirm the process can serve requests and reach critical dependencies, not run a full test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep secrets out of the Compose file
&lt;/h2&gt;

&lt;p&gt;Hardcoded passwords in &lt;code&gt;docker-compose.yml&lt;/code&gt; end up in Git history. Use an &lt;code&gt;.env&lt;/code&gt; file that is excluded from version control, or Docker secrets for more sensitive values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env.production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set file permissions to &lt;code&gt;600&lt;/code&gt;, and keep a separate env file per environment. Rotate anything that was ever committed, even briefly.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Do not publish database ports
&lt;/h2&gt;

&lt;p&gt;A common mistake is &lt;code&gt;ports: - "5432:5432"&lt;/code&gt; on the database service. On a public VPS, that exposes Postgres to the internet, and Docker's iptables rules can bypass host firewalls like UFW.&lt;/p&gt;

&lt;p&gt;Services on the same Compose network can already reach each other by name. Only publish the ports your reverse proxy needs, and bind them to localhost if the proxy runs on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Put a reverse proxy in front
&lt;/h2&gt;

&lt;p&gt;You want TLS, HTTP to HTTPS redirects, and the ability to run several apps on one machine. Caddy and Traefik both handle Let's Encrypt certificates automatically. Caddy wins on simplicity; Traefik wins when you want routing driven by container labels.&lt;/p&gt;

&lt;p&gt;Either way, the proxy is the only thing that should listen on ports 80 and 443.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Use named volumes and back them up
&lt;/h2&gt;

&lt;p&gt;Named volumes survive container recreation and are easy to find:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A volume is not a backup. Schedule logical dumps (&lt;code&gt;pg_dump&lt;/code&gt;) to object storage such as S3 or a compatible provider, keep several days of history, and test a restore at least once a quarter. Untested backups are a hope, not a plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Cap log size
&lt;/h2&gt;

&lt;p&gt;Docker's default &lt;code&gt;json-file&lt;/code&gt; driver grows without limit. A chatty container can fill the disk and take down everything on the host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;x-logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;default-logging&lt;/span&gt;
  &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-file&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
    &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*default-logging&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a YAML anchor keeps the setting consistent across services. If you need search and retention, ship logs to a central store instead of keeping them on the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Set resource limits
&lt;/h2&gt;

&lt;p&gt;One runaway process should not starve the rest. Compose supports memory and CPU limits under &lt;code&gt;deploy.resources&lt;/code&gt;, which modern Docker Compose applies outside Swarm as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start generous, watch real usage for a week, then tighten.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Plan how you deploy updates
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker compose up -d --build&lt;/code&gt; on the server works, but it rebuilds on the production machine and briefly stops the old container before the new one is ready. Better options, from simplest to most robust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build images in CI, push to a registry, and only &lt;code&gt;pull&lt;/code&gt; and &lt;code&gt;up -d&lt;/code&gt; on the server.&lt;/li&gt;
&lt;li&gt;Run two instances behind the proxy and replace them one at a time.&lt;/li&gt;
&lt;li&gt;Use a blue/green setup where the proxy switches traffic only after the new version passes its health check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two are the basis of &lt;a href="https://peon.sh/blogs/zero-downtime-deployments-docker" rel="noopener noreferrer"&gt;zero downtime deployments with Docker&lt;/a&gt;, and they depend on the health checks from step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Harden the host
&lt;/h2&gt;

&lt;p&gt;Compose settings do not help if the host is weak. At minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disable SSH password login and use keys.&lt;/li&gt;
&lt;li&gt;Enable unattended security updates.&lt;/li&gt;
&lt;li&gt;Allow only ports 22, 80, and 443 at the firewall or provider level.&lt;/li&gt;
&lt;li&gt;Run containers as a non-root user where the image supports it.&lt;/li&gt;
&lt;li&gt;Keep enough free disk: prune old images with &lt;code&gt;docker image prune&lt;/code&gt; on a schedule.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. Monitor from the outside
&lt;/h2&gt;

&lt;p&gt;Internal health checks tell Docker what is wrong. External monitoring tells you. A simple uptime checker hitting your public URL every minute, plus alerts on disk usage and memory, catches most incidents before users report them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;None of these steps is complicated, but skipping any one of them is how a small outage turns into a long night. The order above also works as a review checklist for an existing server: go through it top to bottom and fix whatever is missing.&lt;/p&gt;

&lt;p&gt;If you want a longer walkthrough with a complete example file, this &lt;a href="https://peon.sh/blogs/docker-compose-production-deployment" rel="noopener noreferrer"&gt;production Docker Compose guide&lt;/a&gt; goes deeper on env handling, networking, and deployment workflow.&lt;/p&gt;

&lt;p&gt;A single VPS with a disciplined Compose setup will carry you further than most people expect. Move to an orchestrator when you genuinely need multi-node scheduling, not because the Compose file on your laptop was never production ready.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>selfhosted</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Letting AI Agents Deploy to Your Own Servers With MCP (Without Handing Them Root)</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:02:33 +0000</pubDate>
      <link>https://dev.to/offpage_prince/letting-ai-agents-deploy-to-your-own-servers-with-mcp-without-handing-them-root-12na</link>
      <guid>https://dev.to/offpage_prince/letting-ai-agents-deploy-to-your-own-servers-with-mcp-without-handing-them-root-12na</guid>
      <description>&lt;p&gt;Coding agents got good at writing code long before they got safe at running it. The moment you ask Cursor or Claude to "deploy this and check the logs", you hit a real question: what exactly is the agent allowed to touch?&lt;/p&gt;

&lt;p&gt;The lazy answer is an SSH key or a cloud admin token pasted into a config file. It works, and it is also how you end up with an agent that can &lt;code&gt;rm -rf&lt;/code&gt; a production volume because it misread a stack trace.&lt;/p&gt;

&lt;p&gt;This post walks through a better pattern for self-hosted infrastructure: expose deployment operations to agents through the Model Context Protocol (MCP), and let the same role based access control that governs your human team govern the agent too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP changes
&lt;/h2&gt;

&lt;p&gt;MCP is a standard way for an AI client to discover and call tools on a server. Instead of the agent improvising shell commands, it sees a typed catalog: &lt;code&gt;list_services&lt;/code&gt;, &lt;code&gt;deploy&lt;/code&gt;, &lt;code&gt;get_logs&lt;/code&gt;, &lt;code&gt;set_env&lt;/code&gt;, and so on. Each tool has a schema, and the server decides what each call is allowed to do.&lt;/p&gt;

&lt;p&gt;That shift matters for operations work for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The surface is explicit.&lt;/strong&gt; You can read the tool list and know what the agent can do. There is no "anything bash can do" category.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization lives on the server.&lt;/strong&gt; The client sends a token; the server checks it on every call. The agent cannot talk its way past a permission check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions are auditable.&lt;/strong&gt; Tool calls go through the same API as your dashboard, so they land in the same logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The threat model in plain terms
&lt;/h2&gt;

&lt;p&gt;Before wiring anything up, write down what you are protecting against. For most small teams it looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent misunderstands an instruction and changes the wrong service.&lt;/li&gt;
&lt;li&gt;The agent leaks a credential into a chat transcript or a commit.&lt;/li&gt;
&lt;li&gt;A prompt injection in a README, issue, or log line convinces the agent to do something you never asked for.&lt;/li&gt;
&lt;li&gt;A token issued "just for testing" never gets revoked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are solved by a smarter model. They are solved by scoping.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scoping checklist that works
&lt;/h2&gt;

&lt;p&gt;Whatever platform you use, these rules hold up well:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a per-person, per-workspace token.&lt;/strong&gt; Never share one token across a team, and never reuse your own admin token for an agent that works on a single project. If your platform supports it, create a separate user or membership for automation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inherit roles, do not invent them.&lt;/strong&gt; The agent should get exactly the permissions of the human who issued the token, or less. If a developer can only deploy to the &lt;code&gt;staging&lt;/code&gt; project, their agent should hit the same wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave out interactive shells.&lt;/strong&gt; Shell access is the escape hatch that makes every other control pointless. Deployments, restarts, env changes, and log reads cover nearly everything an agent needs. Keep terminals for humans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat tokens like passwords.&lt;/strong&gt; Keep them in your MCP client config or a secret manager, not in repos. Rotate by revoking and reissuing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer reversible actions.&lt;/strong&gt; Rollback should be one tool call. If an agent can deploy, it should also be able to undo its own deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;I have been testing this pattern with &lt;a href="https://peon.sh/docs/mcp" rel="noopener noreferrer"&gt;Peon's MCP server docs&lt;/a&gt; as the reference setup, because it follows the checklist closely. Peon is an open source, self-hostable deployment platform, and its MCP endpoint is part of the app itself rather than a side script.&lt;/p&gt;

&lt;p&gt;The client configuration is short. In Cursor or Claude Desktop you add a streamable HTTP server with a bearer token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"peon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://app.peon.sh/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer peon_xxxxxxxx"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you self-host, the URL becomes &lt;code&gt;https://your-domain/mcp&lt;/code&gt;. The token is created in the dashboard under Keys and Tokens, is scoped to one workspace, and inherits the creator's role. An owner or admin token can manage servers; a project member token only reaches the projects that member belongs to. The details are in the docs on &lt;a href="https://peon.sh/docs/workspaces-and-roles" rel="noopener noreferrer"&gt;workspace and project roles&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two design choices are worth copying even if you build your own server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shell exec tools are not registered on MCP.&lt;/strong&gt; The agent can deploy, restart, roll back, read logs, and manage env vars, but it cannot open a shell on a server or inside a container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same RBAC applies to the API, the MCP server, and the in-app assistant.&lt;/strong&gt; The UI is not the security boundary; the API is.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A realistic agent session
&lt;/h2&gt;

&lt;p&gt;Here is the kind of loop that works well once scoping is in place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"List services in the &lt;code&gt;blog&lt;/code&gt; project and tell me which one failed its last deploy."&lt;/li&gt;
&lt;li&gt;The agent calls the list and deployment tools, finds a failed build, and pulls the build logs.&lt;/li&gt;
&lt;li&gt;It spots a missing &lt;code&gt;DATABASE_URL&lt;/code&gt;, explains the fix, and asks before setting it.&lt;/li&gt;
&lt;li&gt;After you confirm, it sets the variable, triggers a redeploy, and watches the status.&lt;/li&gt;
&lt;li&gt;If the new deployment fails health checks, it rolls back and reports what happened.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing in that loop needed root. Every step is a typed call that your platform can log and deny. If you want a full example, there is a &lt;a href="https://peon.sh/blogs/ai-agents-deploy-apps-mcp-peon" rel="noopener noreferrer"&gt;walkthrough of an agent shipping an app end to end&lt;/a&gt; that covers the same flow with screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails on the client side
&lt;/h2&gt;

&lt;p&gt;Server side scoping is the foundation, but a few client habits help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Require confirmation for writes.&lt;/strong&gt; Most MCP clients can ask before running a tool. Leave that on for anything that deploys, deletes, or changes env.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep read-only tokens for exploration.&lt;/strong&gt; If your role model supports a viewer role, issue a viewer token for "what is running and why is it slow" sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be suspicious of instructions found in data.&lt;/strong&gt; Logs, READMEs, and issue text can contain text that looks like instructions. Tell the agent explicitly that tool output is data, not a command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the audit trail weekly.&lt;/strong&gt; It takes five minutes and catches forgotten tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to use an agent for deploys
&lt;/h2&gt;

&lt;p&gt;Agents are great at the boring middle of operations: reading logs, correlating a failed deploy with a config change, and doing the obvious fix. They are a poor fit for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First-time infrastructure setup where you need to understand every choice.&lt;/li&gt;
&lt;li&gt;Database migrations with data loss risk.&lt;/li&gt;
&lt;li&gt;Anything involving billing, DNS for your primary domain, or deleting backups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep those human, or at least human-approved step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;MCP gives you a clean contract between an AI client and your infrastructure. The contract is only as safe as the permissions behind it, so start from the rules above: personal scoped tokens, inherited roles, no shell tools, reversible actions, and an audit trail.&lt;/p&gt;

&lt;p&gt;If you run your own servers, this is one of the few places where self-hosting makes AI tooling safer rather than riskier, because you control both the tool catalog and the permission model. I would love to hear how others are scoping agent access in their own stacks.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>devops</category>
      <category>selfhosted</category>
      <category>ai</category>
    </item>
    <item>
      <title>Deploy Peon on a DigitalOcean Droplet (self-hosted Docker PaaS)</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Fri, 18 Sep 2026 12:30:50 +0000</pubDate>
      <link>https://dev.to/offpage_prince/deploy-peon-on-a-digitalocean-droplet-self-hosted-docker-paas-bkj</link>
      <guid>https://dev.to/offpage_prince/deploy-peon-on-a-digitalocean-droplet-self-hosted-docker-paas-bkj</guid>
      <description>&lt;p&gt;Peon is an open-source, self-hostable deployment platform. Think Vercel or Heroku workflows on a server you control: Git deploys, Docker Compose, databases, workspaces, and RBAC.&lt;/p&gt;

&lt;p&gt;This guide walks through a clean DigitalOcean droplet setup. Same pattern works on Hetzner, Vultr, and Contabo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://peon.sh/" rel="noopener noreferrer"&gt;https://peon.sh/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Peon-sh/Peon" rel="noopener noreferrer"&gt;https://github.com/Peon-sh/Peon&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;License: MIT&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you will build
&lt;/h2&gt;

&lt;p&gt;A DigitalOcean Ubuntu droplet running Peon so you can connect the server over SSH and deploy apps from Git.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;DigitalOcean account&lt;/li&gt;
&lt;li&gt;SSH key added to DigitalOcean&lt;/li&gt;
&lt;li&gt;Domain name (optional, recommended for TLS)&lt;/li&gt;
&lt;li&gt;Basic SSH comfort&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Create the droplet
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create an Ubuntu 24.04 LTS droplet (2 GB RAM minimum; 4 GB preferred).&lt;/li&gt;
&lt;li&gt;Choose your SSH key.&lt;/li&gt;
&lt;li&gt;Note the public IPv4 address.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  2. Point DNS (optional)
&lt;/h2&gt;

&lt;p&gt;Create an A record for peon.example.com to the droplet IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Harden basics
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@YOUR_DROPLET_IP
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Install Docker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
docker &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Install Peon
&lt;/h2&gt;

&lt;p&gt;Follow current official docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;README: &lt;a href="https://github.com/Peon-sh/Peon" rel="noopener noreferrer"&gt;https://github.com/Peon-sh/Peon&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://peon.sh/docs" rel="noopener noreferrer"&gt;https://peon.sh/docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typical flow: clone repo, configure .env secrets, start Postgres/queue, migrate, run app + worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Open Peon and add the droplet
&lt;/h2&gt;

&lt;p&gt;Add the server over SSH, create a workspace/project, deploy a sample Git repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Verify
&lt;/h2&gt;

&lt;p&gt;App URL responds, deploy logs clean, optional second project for RBAC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Peon vs Coolify / Dokploy
&lt;/h2&gt;

&lt;p&gt;All three are strong self-hosted PaaS options. Peon focuses on workspace/project tenancy, audit logs, and MCP hooks for AI agents, while staying MIT and self-host free.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>docker</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Peon vs Coolify: picking a self-hosted PaaS in 2026</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Fri, 04 Sep 2026 12:19:56 +0000</pubDate>
      <link>https://dev.to/offpage_prince/peon-vs-coolify-picking-a-self-hosted-paas-in-2026-4m7a</link>
      <guid>https://dev.to/offpage_prince/peon-vs-coolify-picking-a-self-hosted-paas-in-2026-4m7a</guid>
      <description>&lt;p&gt;Managed platforms are excellent until the invoice and the lock-in start arguing with your roadmap. Seat pricing, usage cliffs, and "you can leave anytime" exports that are never quite painless push a lot of teams toward a different model: keep the git-push UX, run the apps on hardware you control.&lt;/p&gt;

&lt;p&gt;That is the promise of a self-hosted PaaS. Two names that keep showing up in that conversation are [Coolify] and [&lt;a href="https://peon.sh/" rel="noopener noreferrer"&gt;Peon&lt;/a&gt;]. Both turn a Linux box into something closer to Heroku or Vercel, without giving up the server. This piece is a practical comparison for developers, DevOps folks, and SaaS founders who are deciding which one fits.&lt;/p&gt;

&lt;p&gt;Why people leave managed PaaS&lt;/p&gt;

&lt;p&gt;The usual triggers are boring and expensive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Per-seat bills that grow faster than headcount value. Inviting a contractor or a designer should not feel like a budget meeting.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vendor gravity. Preview deploys, edge features, and proprietary build tooling are sticky. Moving later is harder than starting elsewhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wanting a real VPS. Many teams already pay for Hetzner, DigitalOcean, AWS EC2, or a spare bare-metal machine. Paying again for someone else's orchestration on top feels redundant.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Data and networking control. Some products need private networking, unusual databases, or compliance stories that managed PaaS plans treat as enterprise upsells.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that means managed PaaS is bad. It means the tradeoff stopped matching the team.&lt;/p&gt;

&lt;p&gt;What "self-hosted PaaS" actually means&lt;/p&gt;

&lt;p&gt;A self-hosted PaaS is not Kubernetes-by-default and it is not "SSH and pray." In this category, a control plane talks to your servers (usually over SSH), runs Docker for you, wires a reverse proxy, issues Let's Encrypt certificates, and deploys from Git, images, or Compose files.&lt;/p&gt;

&lt;p&gt;You still own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The VPS or machines where containers run&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DNS for your domains&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Backups and capacity planning&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform owns the workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Connect a repo or image&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Build and roll out with fewer manual Docker steps&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Manage databases, env vars, logs, and rollbacks from a UI&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coolify and Peon both live in that lane. They are closer to each other than either is to "just Docker Compose on a VPS" or to a full cluster platform.&lt;/p&gt;

&lt;p&gt;Coolify, fairly summarized&lt;/p&gt;

&lt;p&gt;&lt;a href="https://coolify.io/" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; is a mature open-source, self-hostable alternative to Vercel, Heroku, Netlify, and Railway. You point it at servers you can reach over SSH and deploy websites, APIs, databases, and a large catalog of one-click services.&lt;/p&gt;

&lt;p&gt;In practice, Coolify is strong when you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A well-known community and lots of tutorials&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A wide one-click service marketplace&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Git push deploys, Docker and Compose, automatic HTTPS&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Multi-server management from one dashboard&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PR preview style workflows and solid day-to-day Docker hosting&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Self-hosting Coolify itself is free; you pay for the machine it runs on. There is also a hosted control-plane option if you prefer not to operate that layer. For solo builders and teams that mainly need "Docker hosting with a good UI," Coolify is a legitimate default recommendation, not a straw man.&lt;/p&gt;

&lt;p&gt;Where Peon aims differently&lt;/p&gt;

&lt;p&gt;&lt;a href="https://peon.sh/" rel="noopener noreferrer"&gt;Peon&lt;/a&gt; sits in the same self-hosted PaaS category: your servers, Docker under the hood, git-based deploys, Compose, databases, custom domains, and automatic HTTPS. The product bet is that once more than one person touches production, the hard problems become permissions, accountability, and how AI tools interact with deploys.&lt;/p&gt;

&lt;p&gt;Project-level RBAC&lt;/p&gt;

&lt;p&gt;Peon models work as workspaces and projects, with roles at both levels. That matters when you want to invite someone to one app without handing them every server in the workspace. Agencies, multi-product startups, and teams with contractors feel this gap quickly on platforms that only think in "team on the whole instance."&lt;/p&gt;

&lt;p&gt;Audit logs on standard plans&lt;/p&gt;

&lt;p&gt;Owner-visible audit history for who changed what across projects, services, servers, and deploys is included on Peon's self-hosted and Cloud offerings, rather than treated as a distant enterprise checkbox. If you care about "who restarted production at 2 a.m.," that is the feature, not a nice-to-have slide.&lt;/p&gt;

&lt;p&gt;MCP for AI agents&lt;/p&gt;

&lt;p&gt;Peon ships a hosted Model Context Protocol (MCP) server so tools like Cursor or Claude can operate against your projects with the same permission model as a human user. The point is not novelty. It is giving agents a governed path to deploys and config instead of pasting raw SSH into a chat window.&lt;/p&gt;

&lt;p&gt;In-app AI assistant&lt;/p&gt;

&lt;p&gt;There is also an in-app assistant that uses your own OpenAI or Anthropic keys, shares the MCP tool surface, and expects approval before mutating production. That is useful for "how do I wire this domain" questions and for proposed actions you can accept or reject in the UI.&lt;/p&gt;

&lt;p&gt;Pricing shape: $3 per project Cloud, unlimited seats&lt;/p&gt;

&lt;p&gt;Peon Cloud is priced per project (published at $3/month or $30/year), with unlimited servers and unlimited team members on that model. Self-hosting the control plane is free and open source. Apps still run on infrastructure you bring. The intent is predictable cost that tracks how you organize products, not seat count or every extra connected box.&lt;/p&gt;

&lt;p&gt;For a side-by-side feature view maintained by the Peon team, see &lt;a href="https://peon.sh/compare/peon-vs-coolify" rel="noopener noreferrer"&gt;Peon vs Coolify&lt;/a&gt;. Treat any comparison table as a starting point and verify current plan details on both sites before you buy.&lt;/p&gt;

&lt;p&gt;Who should pick which&lt;/p&gt;

&lt;p&gt;Lean Coolify if you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The larger community gravity and template ecosystem&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A battle-tested Docker PaaS for personal projects or a small shop that mostly needs deploys, SSL, and one-click services&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;To follow abundant third-party guides and stay close to a widely documented stack&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lean Peon if you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Workspace and project RBAC as a first-class concern&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Audit logs without waiting for an enterprise conversation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;MCP and an in-app assistant wired into the same permissions model&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Flat per-project Cloud pricing with unlimited seats, or a free self-hosted control plane with those team features included&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A path that feels closer to "platform for a growing engineering team" than "excellent solo Docker dashboard"&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many people will be happy on either. The wrong choice is usually picking on vibes, then discovering you needed project isolation or that you did not need AI agent hooks at all.&lt;/p&gt;

&lt;p&gt;Quick start (without the sales pitch)&lt;/p&gt;

&lt;p&gt;If you try Peon, the boring successful path is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Bring a Linux server with SSH and Docker (a small Hetzner or DigitalOcean VPS is fine for experiments).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use Peon Cloud or self-host the control plane.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Connect the server, link a Git source, create a project and service, deploy.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The official walkthroughs live in the &lt;a href="https://peon.sh/docs" rel="noopener noreferrer"&gt;Peon docs&lt;/a&gt; (introduction, self-hosting, first deployment, roles). The code is on &lt;a href="https://github.com/Peon-sh/Peon" rel="noopener noreferrer"&gt;GitHub at Peon-sh/Peon&lt;/a&gt; if you want to read it before you install anything.&lt;/p&gt;

&lt;p&gt;For Coolify, start from their site and docs the same way: one server, one app, prove the loop, then add teammates.&lt;/p&gt;

&lt;p&gt;Honest caveats&lt;/p&gt;

&lt;p&gt;Self-hosted PaaS removes platform markup. It does not remove infrastructure cost or ops ownership.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You still pay for the VPS (or several). A $3 control plane or a free self-hosted panel does not include CPU, RAM, disk, or bandwidth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You own failure modes. Disk full, bad Docker upgrade, mis-pointed DNS, and backup restore drills are yours whether the UI is pretty or not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Migration is work. Moving from Vercel or from Coolify to Peon (or the reverse) is incremental at best: non-prod first, same Git repo, then cut over when you trust the path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;AI features need keys and judgment. MCP and chat assistants are powerful; treat production mutations like you would treat a junior with root, with approvals on.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Community size differs. Coolify currently has more ambient internet knowledge around it. Peon is newer in that social sense; weigh docs quality and your willingness to file issues.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple decision rule&lt;/p&gt;

&lt;p&gt;If your main job is "get containers on my VPS with git push and HTTPS," both Coolify and Peon can do that job. Prefer Coolify when community breadth and one-click catalog depth matter most. Prefer Peon when project isolation, auditability, and governed AI/agent workflows are part of how your team already works, or how you expect to work in 2026.&lt;/p&gt;

&lt;p&gt;Either way, keep the apps on servers you can SSH into, keep backups off-box, and pick the panel your future teammates will not hate. That is the whole self-hosted PaaS bet in one sentence.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Deploy your apps on your server in clicks</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Wed, 26 Aug 2026 07:20:51 +0000</pubDate>
      <link>https://dev.to/offpage_prince/deploy-your-appson-your-server-in-clicks-4fdn</link>
      <guid>https://dev.to/offpage_prince/deploy-your-appson-your-server-in-clicks-4fdn</guid>
      <description>&lt;p&gt;Peon is an open-source application deployment platform you can run yourself: a practical Vercel alternative, and a stand-in for Heroku, Netlify and Railway, when you want to push websites, databases, apps and one-click templates onto hardware you control.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
