<?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: Cyrille Sepele</title>
    <description>The latest articles on DEV Community by Cyrille Sepele (@sepcy).</description>
    <link>https://dev.to/sepcy</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%2F3889620%2F7478edcd-5cf2-4d7e-b8b2-3bbce474ea8d.png</url>
      <title>DEV Community: Cyrille Sepele</title>
      <link>https://dev.to/sepcy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sepcy"/>
    <language>en</language>
    <item>
      <title>Build a POS receipt printer in Node.js</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:27:34 +0000</pubDate>
      <link>https://dev.to/sepcy/build-a-pos-receipt-printer-in-nodejs-31li</link>
      <guid>https://dev.to/sepcy/build-a-pos-receipt-printer-in-nodejs-31li</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build Receiptful, the printing API used in this tutorial. The Node and Express parts apply whatever you print with.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have orders coming into your point of sale, and you want each one to print on the thermal printer at the counter. This is a complete walkthrough of a small Node service that does exactly that. By the end you will have an endpoint you can POST an order to and watch paper come out.&lt;/p&gt;

&lt;p&gt;There is nothing to install next to the printer for this tutorial to work, and no ESC/POS to write by hand. You send HTML, Receiptful prints it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;You need two things from the &lt;a href="https://console.receiptful.io" rel="noopener noreferrer"&gt;console&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A paired printer, which gives you a &lt;strong&gt;printer ID&lt;/strong&gt;. If you have not done this yet, the &lt;a href="https://receiptful.io/blog/getting-started" rel="noopener noreferrer"&gt;getting started guide&lt;/a&gt; walks through it in a couple of minutes.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;API key&lt;/strong&gt; (the &lt;code&gt;rf_live_…&lt;/code&gt; value), created under API keys and shown only once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the code side you need Node 18 or newer, so that &lt;code&gt;fetch&lt;/code&gt; is available globally with no extra dependency. We will use TypeScript, but the same code works in plain JavaScript if you drop the types.&lt;/p&gt;

&lt;p&gt;Put your credentials in the environment rather than in the source:&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;export &lt;/span&gt;&lt;span class="nv"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"rf_live_3f9c…"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RECEIPTFUL_PRINTER_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: model the order
&lt;/h2&gt;

&lt;p&gt;Start with the shape of an order. Yours will have more fields, but this is enough to print a useful receipt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// in cents, to avoid float rounding&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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;Keeping money in cents and formatting only at the edges saves you from the classic floating point rounding bugs that show up as a receipt total that is one cent off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: render the order as HTML
&lt;/h2&gt;

&lt;p&gt;This is the part that decides how the receipt looks. Receiptful converts the HTML you send into ESC/POS for your specific printer, so you get to lay a receipt out with tags you already know instead of byte codes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;&amp;lt;td align="right"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;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;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grandTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;h1 style="text-align:center"&amp;gt;CAFE MILA&amp;lt;/h1&amp;gt;
    &amp;lt;p style="text-align:center"&amp;gt;Order #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
    &amp;lt;hr&amp;gt;
    &amp;lt;table width="100%"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/table&amp;gt;
    &amp;lt;hr&amp;gt;
    &amp;lt;table width="100%"&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;b&amp;gt;TOTAL&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td align="right"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;grandTotal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/table&amp;gt;
    &amp;lt;p style="text-align:center"&amp;gt;Thank you&amp;lt;/p&amp;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;Receipt paper is narrow, so keep the layout to a single column and let text wrap. There is more to say about &lt;a href="https://receiptful.io/blog/designing-receipts-with-html-for-58mm" rel="noopener noreferrer"&gt;designing for a 58mm roll&lt;/a&gt;, but this is enough to print something clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: send it to the printer
&lt;/h2&gt;

&lt;p&gt;Now POST the HTML to the printer's job endpoint. One authenticated request creates the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.receiptful.io/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRINTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_PRINTER_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;printReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PRINTER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;),&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Print request failed (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&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;The response is the job that was created, and a second or so later the receipt prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9281&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"printer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lifetime_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-16T10:24:01Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-16T10:34:01Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-16T10:24:01Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: wrap it in an endpoint
&lt;/h2&gt;

&lt;p&gt;Put that behind a route so your checkout can call it. Here it is with Express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders/:id/print&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;printReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not queue receipt&lt;/span&gt;&lt;span class="dl"&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;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;202 Accepted&lt;/code&gt; is deliberate. The job is queued, not yet confirmed printed, and that distinction matters at a busy counter. The next step is how you close that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: confirm it actually printed
&lt;/h2&gt;

&lt;p&gt;A receipt endpoint that returns success the moment the request is accepted will happily report a print that never reached paper, because the printer was off or out of range. If it matters that the receipt printed, poll the job until it reaches a final state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PRINTER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&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;A job moves through &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;notification_sent&lt;/code&gt;, &lt;code&gt;notification_received&lt;/code&gt;, &lt;code&gt;printing&lt;/code&gt;, and then &lt;code&gt;completed&lt;/code&gt;. If the printer never answers, the job &lt;code&gt;expired&lt;/code&gt; instead of printing a stale order, which is exactly what you want at closing time. How you react to &lt;code&gt;failed&lt;/code&gt; or &lt;code&gt;expired&lt;/code&gt;, whether that means a retry, an on-screen alert, or a reprint button for staff, depends on your counter, so it is worth deciding on purpose rather than by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  That is the whole thing
&lt;/h2&gt;

&lt;p&gt;Roughly sixty lines and you have order to paper, with a real status check rather than a hopeful fire and forget. No print server, no drivers, no ESC/POS.&lt;/p&gt;

&lt;p&gt;The first 20 receipts each month are free, no card required, so you can build this against a real printer today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://console.receiptful.io" rel="noopener noreferrer"&gt;Open the console&lt;/a&gt; to grab a printer ID and key, or read the &lt;a href="https://api.receiptful.io/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; for every field.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>api</category>
    </item>
    <item>
      <title>GitLab CE Comes Without a Runner: Why Nothing Executes Your Pipelines</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Wed, 12 Aug 2026 21:31:26 +0000</pubDate>
      <link>https://dev.to/sepcy/gitlab-ce-comes-without-a-runner-why-nothing-executes-your-pipelines-17mp</link>
      <guid>https://dev.to/sepcy/gitlab-ce-comes-without-a-runner-why-nothing-executes-your-pipelines-17mp</guid>
      <description>&lt;p&gt;You installed GitLab Community Edition, pushed a &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;, and watched&lt;br&gt;
the pipeline sit at &lt;strong&gt;pending&lt;/strong&gt; until it went grey. No error, no failed job,&lt;br&gt;
nothing in the logs worth reading.&lt;/p&gt;

&lt;p&gt;Nothing is broken. Your instance has no runners, and it never had any.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a fresh instance has none
&lt;/h2&gt;

&lt;p&gt;GitLab is two things that people assume are one thing. There is the application:&lt;br&gt;
repositories, issues, merge requests, the CI/CD system that reads your&lt;br&gt;
&lt;code&gt;.gitlab-ci.yml&lt;/code&gt; and builds a pipeline out of it. And there is GitLab Runner: a&lt;br&gt;
separate program, on a separate machine, that actually executes jobs.&lt;/p&gt;

&lt;p&gt;The Omnibus package installs the first. It does not install the second, and it&lt;br&gt;
does not come with any machines to run it on.&lt;/p&gt;

&lt;p&gt;The confusion comes from GitLab.com, where shared runners are switched on by&lt;br&gt;
default and most people's first experience of CI is that it simply works. That&lt;br&gt;
shared fleet is hardware GitLab owns and operates as part of their hosted&lt;br&gt;
service. It is not part of the software you downloaded, so it does not come&lt;br&gt;
across when you run your own instance.&lt;/p&gt;

&lt;p&gt;So on a self-managed install, GitLab will happily accept your pipeline&lt;br&gt;
definition, parse it, create the jobs, and queue them. Then it waits for a&lt;br&gt;
runner to ask for work. If no runner ever asks, the jobs wait indefinitely.&lt;/p&gt;
&lt;h2&gt;
  
  
  Confirming it in thirty seconds
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;Admin Area → CI/CD → Runners&lt;/strong&gt; on your instance. If the list is empty,&lt;br&gt;
that is your answer.&lt;/p&gt;

&lt;p&gt;For a single project, &lt;strong&gt;Settings → CI/CD → Runners&lt;/strong&gt; shows the same thing scoped&lt;br&gt;
narrower. A fresh instance shows nothing in either place.&lt;/p&gt;

&lt;p&gt;From the command line on the GitLab server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;gitlab-rails runner &lt;span class="s2"&gt;"puts Ci::Runner.count"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints &lt;code&gt;0&lt;/code&gt;, no runner has ever been registered against this instance.&lt;/p&gt;

&lt;p&gt;One thing worth ruling out at the same time: a job can also sit pending when&lt;br&gt;
runners &lt;em&gt;do&lt;/em&gt; exist but none of them match the job's tags. If your runner list is&lt;br&gt;
not empty, check whether your jobs specify &lt;code&gt;tags:&lt;/code&gt; that no runner carries. That&lt;br&gt;
is a different problem with a different fix, and it is the second most common&lt;br&gt;
cause after this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your three ways out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install a runner yourself
&lt;/h3&gt;

&lt;p&gt;The traditional answer. Take a machine, install the &lt;code&gt;gitlab-runner&lt;/code&gt; package,&lt;br&gt;
register it against your instance with a token from the runners page, and pick&lt;br&gt;
an executor. The Docker executor is the usual choice because it gives each job a&lt;br&gt;
clean container.&lt;/p&gt;

&lt;p&gt;This is genuinely the cheapest option on day one, and if you have a spare server&lt;br&gt;
and one project that never changes, it may stay that way. Budget an afternoon&lt;br&gt;
for the first one, less if you have done it before.&lt;/p&gt;

&lt;p&gt;The cost arrives later, and it arrives quietly. Runner versions drift out of&lt;br&gt;
support. Disks fill with old build caches until a job fails for reasons that&lt;br&gt;
look nothing like a disk problem. The machine accumulates undocumented local&lt;br&gt;
changes, and eventually nobody is quite sure how to rebuild it. None of this is&lt;br&gt;
hard, exactly. It is just permanent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build a runner fleet
&lt;/h3&gt;

&lt;p&gt;At real scale you stop managing machines individually and start managing a&lt;br&gt;
system: the Kubernetes executor, or Docker Machine autoscaling, defined in&lt;br&gt;
Terraform, with runners created and destroyed on demand.&lt;/p&gt;

&lt;p&gt;If you already run Kubernetes, this is probably the right answer and you should&lt;br&gt;
skip the rest of this post. It is worth being clear-eyed that it is a platform&lt;br&gt;
project rather than a task, with a platform project's ongoing maintenance. Teams&lt;br&gt;
regularly underestimate this by a factor of several because the first working&lt;br&gt;
version comes together quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rent a dedicated runner
&lt;/h3&gt;

&lt;p&gt;Someone else provisions the machine, installs the runner, registers it against&lt;br&gt;
your instance, and keeps it patched. You get a single-tenant VM that behaves&lt;br&gt;
like any other runner on your runners page.&lt;/p&gt;

&lt;p&gt;The trade you are making: you stop owning the machine's lifecycle, and you&lt;br&gt;
accept that build compute runs outside your network. Which brings us to the part&lt;br&gt;
that matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The requirement nobody mentions upfront
&lt;/h2&gt;

&lt;p&gt;If you rent runners, your GitLab instance has to be reachable from that runner&lt;br&gt;
over the public internet, on &lt;code&gt;https&lt;/code&gt;, with a valid certificate.&lt;/p&gt;

&lt;p&gt;That is not a vendor preference, it is how the protocol works. The runner polls&lt;br&gt;
your instance for jobs, and registration calls your instance's API. Both need a&lt;br&gt;
public hostname that resolves to a public address.&lt;/p&gt;

&lt;p&gt;That covers most self-managed instances at companies and agencies, which sit on&lt;br&gt;
a real domain behind a real certificate. It does not cover an air-gapped&lt;br&gt;
instance, a VPN-only instance, or one bound to a private network. If that is&lt;br&gt;
you, the answer is a runner inside your perimeter, and no hosted service can&lt;br&gt;
change that.&lt;/p&gt;

&lt;p&gt;Better to know in paragraph twenty than after signing up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being straight about what a runner sees
&lt;/h2&gt;

&lt;p&gt;A runner clones your source in order to build it. That is true of every runner&lt;br&gt;
ever created, including one you install yourself on your own hardware. Anyone&lt;br&gt;
implying otherwise is selling something.&lt;/p&gt;

&lt;p&gt;So the useful question is not whether build compute touches your code. It is&lt;br&gt;
what kind of machine touches it, and who else is on that machine.&lt;/p&gt;

&lt;p&gt;A shared runner is multi-tenant. You did not choose the hardware, you cannot see&lt;br&gt;
what ran on it before your job, and you cannot point at it in an audit. A&lt;br&gt;
dedicated VM is yours alone, in a region you picked, destroyed when you delete&lt;br&gt;
it. That is a posture you can describe to a client security review, which a&lt;br&gt;
shared runner never was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one is actually right for you
&lt;/h2&gt;

&lt;p&gt;If you self-host GitLab because a policy requires source to stay inside your&lt;br&gt;
network, and that policy extends to build compute, install your own runners.&lt;br&gt;
That is the whole answer and the rest is noise.&lt;/p&gt;

&lt;p&gt;If you self-host for the more common reasons, keeping control of your data,&lt;br&gt;
avoiding SaaS lock-in, satisfying a client contract about where the repository&lt;br&gt;
lives, then the repository decision and the build compute decision are separate,&lt;br&gt;
and most teams only ever made the first one deliberately.&lt;/p&gt;

&lt;p&gt;Agencies feel this hardest, because the problem repeats per client. Eight&lt;br&gt;
clients on their own instances means eight runner setups, eight sets of&lt;br&gt;
credentials, eight machines to patch, and an awkward question at the end of&lt;br&gt;
every engagement about what is still sitting on that box.&lt;/p&gt;

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

&lt;p&gt;Your pipelines are pending because a self-managed GitLab ships with no runners,&lt;br&gt;
by design. Install one, build a fleet, or rent one, and pick based on how much&lt;br&gt;
of your attention you want the choice to keep asking for after the first green&lt;br&gt;
pipeline.&lt;/p&gt;

&lt;p&gt;If renting sounds right, &lt;a href="https://rocketrunner.io/self-hosted/" rel="noopener noreferrer"&gt;runners for self-hosted GitLab&lt;/a&gt; explains&lt;br&gt;
how connecting your own instance works, including what stays on your side.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
    </item>
    <item>
      <title>We Cut Our GitLab Build Time by 59% With One Change</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Sat, 16 May 2026 20:58:07 +0000</pubDate>
      <link>https://dev.to/sepcy/we-cut-our-gitlab-build-time-by-59-with-one-change-lle</link>
      <guid>https://dev.to/sepcy/we-cut-our-gitlab-build-time-by-59-with-one-change-lle</guid>
      <description>&lt;p&gt;You know the feeling. You push a one-line fix, open the pipeline, and watch your runner spend two minutes downloading &lt;code&gt;node_modules&lt;/code&gt;. Again. The same &lt;code&gt;node_modules&lt;/code&gt; it downloaded ten minutes ago. On the last push. That was also a one-line fix.&lt;/p&gt;

&lt;p&gt;Shared runners have the memory of a goldfish. And you're paying for it in build minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: shared runners forget everything
&lt;/h2&gt;

&lt;p&gt;GitLab's shared runners are ephemeral by design. Each job gets a clean machine. Great for isolation. Terrible for your afternoon.&lt;/p&gt;

&lt;p&gt;Every single job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker pulls your base images from scratch&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm install&lt;/code&gt; / &lt;code&gt;pip install&lt;/code&gt; / &lt;code&gt;bundle install&lt;/code&gt; downloads every dependency again&lt;/li&gt;
&lt;li&gt;Docker-in-Docker builds re-download every layer, every time&lt;/li&gt;
&lt;li&gt;Your test suite can't reuse compilation artifacts from the previous run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"But there's the &lt;code&gt;cache:&lt;/code&gt; keyword!" Sure. It uploads a tarball to object storage and downloads it on the next run. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uploading and downloading a 500MB archive takes its own sweet time&lt;/li&gt;
&lt;li&gt;Cache misses are silent and frequent (good luck debugging that)&lt;/li&gt;
&lt;li&gt;Docker image layers? The &lt;code&gt;cache:&lt;/code&gt; keyword can't help you there. You end up in a rabbit hole of registry-based workarounds and BuildKit inline caching&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For small projects, whatever. For anything with real dependencies or Docker builds, you feel it on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the cache actually sticks around
&lt;/h2&gt;

&lt;p&gt;When your runner lives on a dedicated machine that doesn't self-destruct after each job, things get better fast:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker layer cache just works.&lt;/strong&gt; Your &lt;code&gt;FROM node:20&lt;/code&gt; isn't pulled every run. Your &lt;code&gt;RUN apt-get install&lt;/code&gt; layer is already built. Docker's native caching does what it was designed to do. No config, no tricks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;/cache&lt;/code&gt; volume persists between jobs.&lt;/strong&gt; GitLab runners support a local cache directory mounted as a Docker volume. On a shared runner, that volume dies with the VM. On a dedicated machine, it stays. Your &lt;code&gt;cache:&lt;/code&gt; directive in &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; writes to local disk instead of round-tripping through S3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker-in-Docker benefits the most.&lt;/strong&gt; If you're building container images in CI, a persistent Docker daemon means every subsequent build reuses layers from previous builds. No registry hacks. No BuildKit configuration. Just Docker doing its thing.&lt;/p&gt;

&lt;p&gt;None of this is magic. It's just what happens when your runner isn't destroyed after every job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof: same job, same project, very different numbers
&lt;/h2&gt;

&lt;p&gt;Here's our &lt;code&gt;build app&lt;/code&gt; job:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmirkzauyb1pkufbv2tpb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmirkzauyb1pkufbv2tpb.png" alt="Shared runner: 1 minute 54 seconds. RocketRunner: 47 seconds." width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same job. Same codebase. &lt;strong&gt;59% faster.&lt;/strong&gt; And that's a warm cache. The first run is comparable to a shared runner. Every run after that benefits from Docker layers and dependencies already sitting on disk.&lt;/p&gt;

&lt;p&gt;The queue time drop matters too. Shared runners serve everyone on GitLab.com, so your job waits in line behind strangers. A dedicated runner picks up your job immediately because it has nothing better to do.&lt;/p&gt;

&lt;p&gt;Now multiply that by 50 pipeline runs a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  "I'll just self-host a runner."
&lt;/h2&gt;

&lt;p&gt;You can. And if you have a dedicated ops person, or you genuinely enjoy debugging Docker daemon crashes on a Saturday morning, go for it.&lt;/p&gt;

&lt;p&gt;For everyone else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provisioning the server and keeping it updated&lt;/li&gt;
&lt;li&gt;Installing and configuring Docker + GitLab Runner&lt;/li&gt;
&lt;li&gt;Monitoring disk space (those Docker layers add up quietly)&lt;/li&gt;
&lt;li&gt;Rotating tokens, managing SSH keys&lt;/li&gt;
&lt;li&gt;Getting paged at 2 am because the runner went offline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache benefits of a persistent runner are real. The Sunday afternoon you lose figuring out why &lt;code&gt;/var/lib/docker&lt;/code&gt; filled up the disk is also real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built instead
&lt;/h2&gt;

&lt;p&gt;This is why &lt;a href="https://rocketrunner.io" rel="noopener noreferrer"&gt;RocketRunner&lt;/a&gt; exists.&lt;/p&gt;

&lt;p&gt;You get a dedicated VM. Real hardware, not a shared slice. Docker and the GitLab runner are installed and registered with your project automatically. Because it's your machine running your Docker daemon, all caching works natively.&lt;/p&gt;

&lt;p&gt;You don't configure any of this. It's a side effect of having a runner that doesn't get thrown away after every job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Typical Node.js setup:&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;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&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;node:20&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CI_COMMIT_REF_SLUG}&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules/&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a shared runner, &lt;code&gt;npm ci&lt;/code&gt; downloads everything every time. The cache round-trip to S3 often takes longer than the install itself. Ironic.&lt;/p&gt;

&lt;p&gt;On RocketRunner, that cache lives on a local volume. First run populates it. Second run reads from disk. Done.&lt;/p&gt;

&lt;p&gt;For Docker builds, the gap gets embarrassing:&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;build-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&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;docker:24.0.5&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:24.0.5-dind&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t myapp:latest.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shared runner: pulls &lt;code&gt;docker:24.0.5&lt;/code&gt;, pulls every layer in your Dockerfile, every time. A 3-minute build that should take 20 seconds. You go make coffee. You come back. It's still pulling.&lt;/p&gt;

&lt;p&gt;RocketRunner: Docker daemon is already running. Base images are cached. Unchanged layers are skipped. It finishes before you can alt-tab away.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this matters (and when it doesn't)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Projects with Docker-in-Docker builds&lt;/li&gt;
&lt;li&gt;Monorepos with large dependency trees&lt;/li&gt;
&lt;li&gt;Teams running 20+ pipelines per day&lt;/li&gt;
&lt;li&gt;Anything where &lt;code&gt;npm install&lt;/code&gt; takes longer than your actual tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Probably overkill:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small projects with minimal dependencies&lt;/li&gt;
&lt;li&gt;Pipelines that only run linters or simple scripts&lt;/li&gt;
&lt;li&gt;Teams running fewer than a handful of pipelines per week&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;RocketRunner starts with a &lt;a href="https://rocketrunner.io" rel="noopener noreferrer"&gt;48-hour free trial&lt;/a&gt;. Setup takes about 2 minutes. Connect your GitLab account, pick a server size, choose a region, and your runner is live.&lt;/p&gt;

&lt;p&gt;Smallest plan runs at $0.018/hr with a $10.59/month cap. Most teams pay between $1-10/month.&lt;/p&gt;

&lt;p&gt;If your pipelines spend more time downloading dependencies than running your actual code, a persistent cache might be all you need.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Cheap Dedicated CI/CD Runners for GitLab: Shared vs Self-Hosted vs Rented</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Sat, 09 May 2026 21:01:17 +0000</pubDate>
      <link>https://dev.to/sepcy/cheap-dedicated-cicd-runners-for-gitlab-shared-vs-self-hosted-vs-rented-2a2a</link>
      <guid>https://dev.to/sepcy/cheap-dedicated-cicd-runners-for-gitlab-shared-vs-self-hosted-vs-rented-2a2a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frsrnp6o0kohe010ewnyg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frsrnp6o0kohe010ewnyg.png" alt="3 ways to run GitLab CI jobs — cost and isolation compared" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your GitLab pipelines are slow, flaky, or eating into your SaaS bill, you've probably looked at your runner setup. There are three ways to run GitLab CI jobs, and the cost difference between them is bigger than most people realise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared runner problem
&lt;/h2&gt;

&lt;p&gt;GitLab's shared runners are the path of least resistance. You don't set anything up, and they work. Until they don't.&lt;/p&gt;

&lt;p&gt;The issues show up gradually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jobs queuing behind other users' workloads&lt;/li&gt;
&lt;li&gt;Inconsistent build times&lt;/li&gt;
&lt;li&gt;No control over the hardware&lt;/li&gt;
&lt;li&gt;Shared filesystem state that causes flaky tests you can't reproduce locally&lt;/li&gt;
&lt;li&gt;No pipeline caching — every job starts cold, every time&lt;/li&gt;
&lt;li&gt;Limited CI minutes on the Free tier (400 min/month on GitLab.com)&lt;/li&gt;
&lt;li&gt;Extra minutes cost $10 per 1,000 if you exceed your allowance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running Docker-in-Docker or anything that needs real isolation, shared runners are a constant source of friction.&lt;/p&gt;

&lt;p&gt;For a solo project or a weekend hack, shared runners are fine. For a team shipping to production, the unpredictability gets expensive fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting: more control, more overhead
&lt;/h2&gt;

&lt;p&gt;The obvious answer is to run your own runner on a VPS. Full control, no queuing, dedicated hardware. A &lt;strong&gt;Hetzner CX23 (4GB RAM)&lt;/strong&gt; costs about &lt;strong&gt;€3.99/month&lt;/strong&gt; on paper — hard to beat.&lt;/p&gt;

&lt;p&gt;The catch is everything else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provision the server&lt;/li&gt;
&lt;li&gt;Install Docker and the GitLab runner binary&lt;/li&gt;
&lt;li&gt;Register it with your project or group (get the token, run the register command, handle the config)&lt;/li&gt;
&lt;li&gt;Keep it updated&lt;/li&gt;
&lt;li&gt;Monitor it&lt;/li&gt;
&lt;li&gt;Remember to destroy it when you're done, or keep paying for it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real cost of self-hosting isn't the €3.99/month server. It's the engineer who updates the runner binary when it falls behind, debugs the registration token when it expires, and gets paged when the disk fills up. If that's 30 minutes a month at a $50/hr developer rate, you've already spent more than the server costs.&lt;/p&gt;

&lt;p&gt;For a team that already owns and operates infrastructure, this overhead is absorbed. For a solo developer, a startup, or anyone who just wants pipelines to work, it's babysitting you didn't sign up for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renting a dedicated runner by the hour
&lt;/h2&gt;

&lt;p&gt;There's a third option that most people haven't considered: &lt;strong&gt;renting a dedicated runner, billed hourly, with zero setup&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You sign in with GitLab&lt;/li&gt;
&lt;li&gt;Pick a server size and region&lt;/li&gt;
&lt;li&gt;The runner is provisioned and registered with your project automatically. No SSH, no config files&lt;/li&gt;
&lt;li&gt;You pay only while the runner exists&lt;/li&gt;
&lt;li&gt;Delete it, and billing stops immediately&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://rocketrunner.io/" rel="noopener noreferrer"&gt;RocketRunner&lt;/a&gt; does this. A &lt;strong&gt;Small runner (4GB RAM, 2 vCPUs)&lt;/strong&gt; costs &lt;strong&gt;$0.018/hr&lt;/strong&gt;, or about &lt;strong&gt;$10.59/month&lt;/strong&gt; maximum if you run it 24/7. Most teams pay far less because they only run it when they need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cost comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;th&gt;Setup time&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitLab shared runners&lt;/td&gt;
&lt;td&gt;Included (with limits)&lt;/td&gt;
&lt;td&gt;0 min&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted on Hetzner CX23&lt;/td&gt;
&lt;td&gt;~$4.71/month + engineering time&lt;/td&gt;
&lt;td&gt;30–60 min&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rented dedicated runner&lt;/td&gt;
&lt;td&gt;$0.018/hr (~$1–10/month typical)&lt;/td&gt;
&lt;td&gt;2 min&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The self-hosted option has the lowest server bill if you run jobs 24/7. But once you factor in the engineering time to set it up and keep it running, renting by the hour is cheaper for most teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  When renting makes sense
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You're a solo developer or small team that doesn't want to maintain infrastructure&lt;/li&gt;
&lt;li&gt;You need full VM isolation (Docker-in-Docker, privileged containers, clean state per run)&lt;/li&gt;
&lt;li&gt;Your pipeline load is unpredictable, and you don't want to pay for idle compute&lt;/li&gt;
&lt;li&gt;You want runners in a specific region (EU or US) for compliance or latency reasons&lt;/li&gt;
&lt;li&gt;You're prototyping and want something live in 2 minutes, not 45&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Shared runners are free but unreliable. Self-hosting is cheap on paper but comes with hidden ops overhead. Renting a dedicated runner by the hour sits in between: full isolation, no setup, and a cost that scales with actual usage — no engineer babysitting required.&lt;/p&gt;

&lt;p&gt;If you've been putting up with slow or flaky GitLab pipelines, it's worth trying a dedicated runner. With a &lt;strong&gt;48-hour free trial&lt;/strong&gt; and no contracts, the cost of finding out is zero.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://rocketrunner.io/" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Spin up a dedicated GitLab runner in 2 minutes&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://rocketrunner.io/" rel="noopener noreferrer"&gt;Get started at rocketrunner.io&lt;/a&gt;&lt;/strong&gt; — card required, no charge for 48 hours.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>devops</category>
      <category>cicd</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
