<?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 exam repeats without double-counting: one tag per paper</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Fri, 06 Nov 2026 05:29:14 +0000</pubDate>
      <link>https://dev.to/offpage_prince/counting-exam-repeats-without-double-counting-one-tag-per-paper-5b7a</link>
      <guid>https://dev.to/offpage_prince/counting-exam-repeats-without-double-counting-one-tag-per-paper-5b7a</guid>
      <description>&lt;p&gt;Disclosure: I work on GTU Hero. We count how often topics repeat in Gujarat Technological University exam papers. This note is about one counting rule that looks trivial and still breaks dashboards when you get it wrong.&lt;/p&gt;

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

&lt;p&gt;A topic counts &lt;strong&gt;once per paper&lt;/strong&gt;, even if that paper asks it twice (compulsory + OR, or two different sub-parts).&lt;/p&gt;

&lt;p&gt;Why: students care about "will this idea show up in my paper?", not "how many times can the setter stamp the same idea". If Green's theorem is Q.1(c) and also hides inside an OR elsewhere, that is still one paper that tested Green's.&lt;/p&gt;

&lt;p&gt;For Mathematics-2 (3110015) that rule turned into a clean table across 9 official papers (Summer 2022 to Summer 2026): Green's, Fourier integral, Laplace IVP, variation of parameters, undetermined coefficients, Euler-Cauchy and power series all landed at &lt;strong&gt;9/9&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny counter
&lt;/h2&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;count_topics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;papers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;papers: list of (paper_id, list_of_topic_tags_including_ORs)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;per_paper&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;tags&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;papers&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="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;per_paper&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="nf"&gt;add&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="c1"&gt;# set = once per paper
&lt;/span&gt;    &lt;span class="n"&gt;totals&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;for&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;per_paper&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;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;totals&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="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&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;per_paper&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&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;c&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;totals&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;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;x&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="c1"&gt;# demo shaped like the Maths 2 guide
&lt;/span&gt;&lt;span class="n"&gt;demo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;laplace_ivp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;          &lt;span class="c1"&gt;# green twice -&amp;gt; once
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;W2025&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fourier_integral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S2026&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;laplace_ivp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;power_series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;count_topics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;demo&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="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;topic&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;c&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;n&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;Run that and &lt;code&gt;green&lt;/code&gt; prints &lt;code&gt;3/3&lt;/code&gt;, not &lt;code&gt;4/3&lt;/code&gt;. The duplicate tag inside S2024 is ignored on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we also had to watch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subject code, not subject name.&lt;/strong&gt; Maths 2 is 3110015 on the older scheme and BE02000011 / BE02R00011 on the newer one. Mixing them inflates or dilutes counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OR options still count.&lt;/strong&gt; Students can pick the OR side, so the topic is "in the paper".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say when text extraction failed.&lt;/strong&gt; Under-counts should be labelled, not hidden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is clever. It is the difference between "Green's came in 9 of 9 papers" and a number nobody can defend.&lt;/p&gt;

&lt;p&gt;The published Maths 2 table (after these checks) is on &lt;a href="https://gtuhero.com/blogs/gtu-maths-2-papers-pyqs-repeated-sums" rel="noopener noreferrer"&gt;GTU Hero&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>education</category>
      <category>beginners</category>
    </item>
    <item>
      <title>A boring data bug: one exam PDF, two file names</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:49:06 +0000</pubDate>
      <link>https://dev.to/offpage_prince/a-boring-data-bug-one-exam-pdf-two-file-names-5dg8</link>
      <guid>https://dev.to/offpage_prince/a-boring-data-bug-one-exam-pdf-two-file-names-5dg8</guid>
      <description>&lt;p&gt;Disclosure: I work on GTU Hero, a site that counts repeated questions in Gujarat Technological University exam papers. This post is about one boring data bug that came up while counting first-year Physics papers, and the small check I would add to any pipeline like this.&lt;/p&gt;

&lt;p&gt;The setup: download every official paper for a subject code, extract text, tag each sub-question with a topic, and count a topic once per paper. For the Physics guide that meant 22 papers across three subject codes, Summer 2022 to Summer 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: the same file under two names
&lt;/h2&gt;

&lt;p&gt;The new-scheme Physics code BE01000021 has a sibling code, BE01R00021. For Winter 2025 and Summer 2026, the repository served a PDF under each code. They were the exact same file, headed "BE01000021/BE01R00021".&lt;/p&gt;

&lt;p&gt;If you count both, every topic in those papers gets counted twice, and a 2 out of 4 topic suddenly looks like 4 out of 6. So the fix is to dedupe on content, not on file name:&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="k"&gt;def&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;path&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="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="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&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;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&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&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;b&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&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;h&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unique_papers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&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;p&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;paths&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="nf"&gt;sha256&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="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&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;return&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;  &lt;span class="c1"&gt;# hash -&amp;gt; every file name that served the same bytes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any hash with more than one file name is a duplicate. Keep one, and log the others so a human can see why the count dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A limit worth knowing
&lt;/h2&gt;

&lt;p&gt;Hashing only catches exact copies. A rescanned or re-exported PDF will have a different hash, so it is still worth glancing at the header text of each paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say it in the output
&lt;/h2&gt;

&lt;p&gt;In the published guide, the two duplicate files are counted once, and the guide says so. That matters because students make real decisions from these numbers. If a duplicate inflates a count and nobody notices, the advice is wrong while the table still looks clean.&lt;/p&gt;

&lt;p&gt;Two rules I follow now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Dedupe by content hash before counting anything.&lt;/li&gt;
&lt;li&gt;Write what you deduped into the output, not only into your logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither is clever. Both are cheaper than explaining a wrong number later.&lt;/p&gt;

&lt;p&gt;If you are curious what the counts look like after this check, the Physics write-up is on &lt;a href="https://gtuhero.com/blogs/gtu-physics-paper-sem-1-2-pyq" rel="noopener noreferrer"&gt;GTU Hero&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>pdf</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Same subject, three codes: handling syllabus versions when indexing exam papers</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Wed, 04 Nov 2026 04:43:06 +0000</pubDate>
      <link>https://dev.to/offpage_prince/same-subject-three-codes-handling-syllabus-versions-when-indexing-exam-papers-fm3</link>
      <guid>https://dev.to/offpage_prince/same-subject-three-codes-handling-syllabus-versions-when-indexing-exam-papers-fm3</guid>
      <description>&lt;p&gt;I work on GTU Hero, a site that organises GTU (Gujarat Technological University) exam papers. This is a small data-modelling note from that work, and it applies to any project that indexes exam papers or course material.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: subject name is not a safe key
&lt;/h2&gt;

&lt;p&gt;GTU Maths 1 exists under three subject codes: 3110014, BE01000041 and BE01R00041. They are all "Maths 1" by name, but the modules are not the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3110014: calculus, sequences and series, Fourier series, multivariable calculus, multiple integrals, matrices. 5 credits.&lt;/li&gt;
&lt;li&gt;BE01000041 (w.e.f. 2024-25): no Fourier series, no matrices. 4 credits.&lt;/li&gt;
&lt;li&gt;BE01R00041 (w.e.f. 2025-26): the same five modules as BE01000041, only the hours and weightage differ.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you key papers by subject name, a student on BE01000041 can end up solving a 3110014 paper and spending time on Fourier series, which is not in their syllabus. Keying by subject code keeps each paper attached to the syllabus version it was set for.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny model
&lt;/h2&gt;

&lt;p&gt;A dictionary from code to modules is enough to start with:&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="n"&gt;SYLLABUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3110014&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequences and series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fourier series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multivariable calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multiple integrals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matrices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BE01000041&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequences and series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multivariable calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multiple integrals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BE01R00041&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequences and series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multivariable calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multiple integrals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that BE01R00041 has the same module set as BE01000041. The differences (hours, weightage) would live in separate fields, so I keep modules as their own set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flagging out-of-syllabus questions
&lt;/h2&gt;

&lt;p&gt;Each question in an old paper is tagged with a module. Given the student's current code, we can flag the questions that are out of syllabus:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;out_of_syllabus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paper_questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student_code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;paper_questions: list of (question_id, module) from an old paper.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SYLLABUS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;student_code&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;qid&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paper_questions&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="n"&gt;old_paper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calculus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fourier series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matrices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multiple integrals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&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="nf"&gt;out_of_syllabus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old_paper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BE01000041&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# ['Q2', 'Q3']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same function works the other way round too. If a student on 3110014 opens a newer paper, you can compare module sets and see what is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth doing early
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Subject codes are stable identifiers. Names get reused across syllabus versions.&lt;/li&gt;
&lt;li&gt;Storing modules per code lets you answer "is this question still relevant?" with a set lookup instead of manual checking.&lt;/li&gt;
&lt;li&gt;When a new code appears, you add one entry instead of reworking your data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Key by subject code, store modules per code, and compute relevance instead of assuming that same name means same syllabus. That is the whole idea, and it is a good first data-modelling exercise if you are learning Python.&lt;/p&gt;

&lt;p&gt;If you want to see the papers this applies to, they are here: &lt;a href="https://gtuhero.com/gtu-papers" rel="noopener noreferrer"&gt;GTU Hero&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datamodeling</category>
      <category>beginners</category>
      <category>education</category>
    </item>
    <item>
      <title>The 8 beginner C programs every GTU first-year should be able to write blind</title>
      <dc:creator>Prince</dc:creator>
      <pubDate>Tue, 03 Nov 2026 04:46:10 +0000</pubDate>
      <link>https://dev.to/offpage_prince/the-8-beginner-c-programs-every-gtu-first-year-should-be-able-to-write-blind-2ofj</link>
      <guid>https://dev.to/offpage_prince/the-8-beginner-c-programs-every-gtu-first-year-should-be-able-to-write-blind-2ofj</guid>
      <description>&lt;p&gt;Disclosure: I work on GTU Hero, a site for GTU (Gujarat Technological University) exam prep. This is aimed at first-year engineering students taking Programming for Problem Solving.&lt;/p&gt;

&lt;p&gt;If you are new to C, these eight programs cover most of the basics you will be tested on: conditions, loops, integer arithmetic and recursion.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Program&lt;/th&gt;
&lt;th&gt;Concept it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Even or odd&lt;/td&gt;
&lt;td&gt;if-else, %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Factorial&lt;/td&gt;
&lt;td&gt;loops, recursion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prime check&lt;/td&gt;
&lt;td&gt;loops, flags, edge cases (1 is not prime)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fibonacci&lt;/td&gt;
&lt;td&gt;updating two variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sum of digits&lt;/td&gt;
&lt;td&gt;% 10 and / 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reverse and palindrome&lt;/td&gt;
&lt;td&gt;temp variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest of three&lt;/td&gt;
&lt;td&gt;comparisons, equal inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Armstrong number&lt;/td&gt;
&lt;td&gt;digit extraction, powers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the simplest one, to show the style GTU expects (write the algorithm too):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Enter a number: "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;scanf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Even number"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Odd number"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edge cases are where marks go. Test prime check with 1 and 2, test largest-of-three with equal values, and test palindrome with a single digit.&lt;/p&gt;

&lt;p&gt;For GTU specifically, PPS appears under codes like 3110003 and BE01000121, so match your code before using old papers. The full list with past paper links is here: &lt;a href="https://gtuhero.com/blogs/gtu-pps-previous-year-paper" rel="noopener noreferrer"&gt;GTU Hero PPS guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>c</category>
      <category>beginners</category>
      <category>programming</category>
      <category>learning</category>
    </item>
    <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>
  </channel>
</rss>
