<?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: SimpleMeteo</title>
    <description>The latest articles on DEV Community by SimpleMeteo (@simplemeteo).</description>
    <link>https://dev.to/simplemeteo</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%2F3902877%2F6f5c90d7-7e5b-45cd-8a7d-6b36d709d4c3.png</url>
      <title>DEV Community: SimpleMeteo</title>
      <link>https://dev.to/simplemeteo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/simplemeteo"/>
    <language>en</language>
    <item>
      <title>How barometer.today draws its isobars</title>
      <dc:creator>SimpleMeteo</dc:creator>
      <pubDate>Mon, 07 Sep 2026 22:47:02 +0000</pubDate>
      <link>https://dev.to/simplemeteo/how-barometertoday-draws-its-isobars-4ega</link>
      <guid>https://dev.to/simplemeteo/how-barometertoday-draws-its-isobars-4ega</guid>
      <description>&lt;p&gt;&lt;a href="https://barometer.today" rel="noopener noreferrer"&gt;barometer.today&lt;/a&gt; shows air pressure for any city: the reading now, the change since yesterday, and the week ahead. It also has a world isobar map, animated hour by hour from a day back to four days ahead. &lt;/p&gt;

&lt;p&gt;This post explains what isobars are and the algorithms that we use to draw them. The code is Python with numpy, scipy, contourpy and shapely, and anyone drawing contour maps from gridded data will meet the same problems.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F915earl8e8jd90ydj4ou.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F915earl8e8jd90ydj4ou.png" alt=" " width="799" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What an isobar is
&lt;/h2&gt;

&lt;p&gt;Air pressure varies from place to place, and a weather map shows this with isobars - lines joining points of equal pressure. Where lines crowd together pressure changes fast over a short distance and the wind is usually strong. Where they spread out the air is calmer. The rings close around centers of high pressure marked H and low pressure, marked L. A deep low with tight rings is a storm. A broad high with loose rings is settled weather.&lt;/p&gt;

&lt;p&gt;The pressure on such a map is sea-level pressure. A barometer in Denver reads about 835 hPa because it sits 1,600 m up, which says nothing about the weather. So the reading is converted to what it would be at sea level, and the map becomes comparable everywhere. With one exception, covered below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;The map is drawn from the ECMWF global forecast. It arrives as a grid of sea-level pressure values every 0.25 degrees, about 28 km, for the whole planet. The model's own time step is three hours; Open-Meteo interpolates that to hourly values. We fetch it from Open-Meteo, which we run on our own server for all SimpleMeteo.com sites. &lt;a href="https://uvi.today" rel="noopener noreferrer"&gt;uvi.today&lt;/a&gt; and &lt;a href="https://pollen.today" rel="noopener noreferrer"&gt;pollen.today&lt;/a&gt; use the same instance.&lt;/p&gt;

&lt;p&gt;The model updates four times a day. After each update a job produces one frame per hour, about 120 in total.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smoothing
&lt;/h2&gt;

&lt;p&gt;A raw model grid is jagged cell to cell, so lines traced straight from it wobble. We use a Gaussian blur with a sigma of one cell, 28 km. Wider blurs give calmer lines but flatten the extremes: with a 110 km blur, a hurricane with a 936 hPa core comes out as a mild 993 hPa low. It could produce a map that looks fine and is wrong.&lt;/p&gt;

&lt;p&gt;The blur has to know about mountains. Over a high plateau sea-level pressure is a calculation about air that does not exist, and it usually comes out several hPa too high. A plain Gaussian would spread those inflated values into the real field around the plateau. Along the Himalayan front this moves lowland values by more than an isobar interval.&lt;/p&gt;

&lt;p&gt;The fix is a normalized convolution. Blur the field with the plateau cells set to zero, blur a mask of ones and zeros the same way, and divide one by the other. Each output cell is then the weighted mean of only the valid cells nearby. Two lines of scipy:&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;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gaussian_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sigma&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;den&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gaussian_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sigma&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;smoothed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;den&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trick handles a missing value: instead of a single null spreading into a 9 by 9 hole after blurring, it contributes nothing and stays a one-cell gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the mask is
&lt;/h2&gt;

&lt;p&gt;The mask is ground above 2,500 m. It comes from a digital elevation model, smoothed to 55 km so that a single peak does not count, then only massifs of at least 265,000 km² are kept. Four regions qualify: the Andes, the Tibetan plateau with the Himalaya and the Pamir, the Greenland ice sheet and Antarctica.&lt;/p&gt;

&lt;p&gt;Over the mask the isobars are still drawn, but dashed. The map stays continuous and the reader is not left with a hole in Asia, while the dashes say the values here are extrapolated. Each line segment is tested against the mask individually, so an isobar crossing the Andes is dashed for exactly the part over high ground.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnr09bmdfxkwlmknp093l.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnr09bmdfxkwlmknp093l.png" alt=" " width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing the lines
&lt;/h2&gt;

&lt;p&gt;Contours are traced with marching squares, the standard algorithm that matplotlib uses. Each grid cell has a value at its four corners. For a given level, say 1012 hPa, the algorithm marks each corner as above or below, which gives 16 possible patterns, and each pattern says where the line enters and leaves the cell. The crossing point along an edge is found by linear interpolation between the corner values. Done for every cell, the pieces join into continuous lines. Done for every level, you have the map.&lt;/p&gt;

&lt;p&gt;A world map adds a twist: longitude wraps around. The algorithm sees a rectangle with left and right edges and would stop every line at 180 degrees. So the grid is padded with two degrees of columns copied from the opposite edge, contoured, and the lines clipped back to the world. They then cross the Pacific date line without a seam.&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;pad&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;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;zp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hstack&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
&lt;span class="n"&gt;xp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concatenate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;lons&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lons&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contourpy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contour_generator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line is then simplified with the Douglas-Peucker algorithm at 0.05 degrees. It removes points that lie within that distance of a straight line between their neighbours, so a nearly straight 900 km isobar collapses to three points while a tight ring around a storm keeps its shape. A frame ends up as a few hundred kilobytes of GeoJSON, under 100 KB compressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the H and L
&lt;/h2&gt;

&lt;p&gt;Centers are found on the smoothed field with min and max filters: a cell is a candidate if it is the extreme within a window of about 850 km. The window is built per band of latitude, so it is a real distance and not a cell count. A fixed cell count would cover only half the distance at 60 degrees north that it covers at the equator.&lt;/p&gt;

&lt;p&gt;A candidate must also stand out. The field within the window must range by at least 3 hPa, or 5 hPa inside 20 degrees of the equator, where the tropical atmosphere has a natural twice-daily tide of 2 to 4 hPa that would otherwise put a letter at every ripple.&lt;/p&gt;

&lt;p&gt;Two details matter in practice. The masked cells are filled with minus infinity for the max filter and plus infinity for the min filter, never NaN. Scipy's filters treat a NaN differently depending on where it falls in the window, and the result is that a real low on the Antarctic coast can silently vanish. And no letter is placed on ground above 1,000 m. In the tropics a high is also dropped when ground above 700 m lies within a degree of it, because the nightly cooling of a highland spills a fictitious high onto the coastal cells around it. Lows are exempt: the Afar heat low sits near sea level inside a ring of highlands and is real.&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;z_hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# for maximum_filter
&lt;/span&gt;&lt;span class="n"&gt;z_lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# for minimum_filter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final step is the one that matters most. Each surviving candidate is refined on the raw, unsmoothed grid: the printed value and position are the true extreme within one degree of the candidate. Smoothing is right for the lines and wrong for the extremes. This way the L over a hurricane carries the model's own minimum, even when the core is only a few cells wide.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vs70w65mxj9nhw2zya4.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vs70w65mxj9nhw2zya4.png" alt=" " width="550" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the animation steady
&lt;/h2&gt;

&lt;p&gt;A center must also appear in the neighbouring hour, within 800 km, in either the frame before or the frame after. A letter that exists for a single frame is noise at the threshold, and dropping it keeps the animation calm. The rule is deliberately loose: a deep Atlantic low can move 500 km in a few hours, and a stricter test took its letter away while the rings were plainly there.&lt;/p&gt;

&lt;p&gt;In the browser, the map is Leaflet. The image layers are created once and only swap their source between frames, and the lines are redrawn on a single canvas. A frame is shown only when its lines and fill have fully decoded. And the number labels on the lines stay in place as long as the same isobar still runs under them, so you can scrub through five days of weather on a phone and the numbers do not jump.&lt;/p&gt;




&lt;p&gt;The map is at &lt;a href="https://barometer.today/en/pressure-map" rel="noopener noreferrer"&gt;barometer.today/pressure-map&lt;/a&gt;. barometer.today is part of the &lt;a href="https://simplemeteo.com" rel="noopener noreferrer"&gt;SimpleMeteo&lt;/a&gt; family with &lt;a href="https://uvi.today" rel="noopener noreferrer"&gt;uvi.today&lt;/a&gt; (UV index), &lt;a href="https://pollen.today" rel="noopener noreferrer"&gt;pollen.today&lt;/a&gt; (pollen), &lt;a href="https://airindex.today" rel="noopener noreferrer"&gt;airindex.today&lt;/a&gt; (air quality) and &lt;a href="https://weatherjourney.com" rel="noopener noreferrer"&gt;weatherjourney.com&lt;/a&gt; (climate history since 1940). All are free, with no accounts, cookies or ads.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why we self-hosted Open-Meteo: AI crawlers, rate limits, and 100 ms we didn't expect to win</title>
      <dc:creator>SimpleMeteo</dc:creator>
      <pubDate>Tue, 28 Apr 2026 17:35:05 +0000</pubDate>
      <link>https://dev.to/simplemeteo/why-we-self-hosted-open-meteo-ai-crawlers-rate-limits-and-100-ms-we-didnt-expect-to-win-cp8</link>
      <guid>https://dev.to/simplemeteo/why-we-self-hosted-open-meteo-ai-crawlers-rate-limits-and-100-ms-we-didnt-expect-to-win-cp8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TL;DR — We run &lt;a href="https://uvi.today" rel="noopener noreferrer"&gt;uvi.today&lt;/a&gt; (UV index), &lt;a href="https://pollen.today" rel="noopener noreferrer"&gt;pollen.today&lt;/a&gt; (pollen forecast) and &lt;a href="https://airindex.today" rel="noopener noreferrer"&gt;airindex.today&lt;/a&gt; (air quality). All three pull from Open-Meteo. AI crawlers pushed us past the free tier, the paid tier worked but still wasn't a fit for crawler-heavy traffic, so we ended up self-hosting Open-Meteo on a single VPS. Disk: ~50 GB. Latency: 90–100 ms faster per request. Cost: less than the paid plan.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a short write-up of how we got there, what the migration actually looked like, and a couple of things we'd flag for anyone thinking about doing the same.&lt;/p&gt;

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

&lt;p&gt;The three sites are simple Next.js apps. Each city page renders server-side and calls Open-Meteo for two datasets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;forecast API&lt;/strong&gt; for temperature, weather code, humidity, etc.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;CAMS air quality API&lt;/strong&gt; for UV index, AQI components, and pollen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We cache responses in an LRU on the Node side (coord-keyed, 60 min TTL) and that's it. No queue, no warm cache jobs, no background workers.&lt;/p&gt;

&lt;p&gt;For the first months, the free public Open-Meteo API was perfect. The free tier is generous:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Per minute&lt;/th&gt;
&lt;th&gt;Per hour&lt;/th&gt;
&lt;th&gt;Per day&lt;/th&gt;
&lt;th&gt;Per month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;300,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source: &lt;a href="https://open-meteo.com/en/pricing" rel="noopener noreferrer"&gt;open-meteo.com/en/pricing&lt;/a&gt;. The free tier is non-commercial; the Standard plan is what you upgrade to once you put ads on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the crawlers showed up
&lt;/h2&gt;

&lt;p&gt;Search Console traffic was modest. Logs were not. Once the sites started ranking on long-tail queries, every AI crawler on the planet decided that a city-by-locale URL grid was an irresistible buffet.&lt;/p&gt;

&lt;p&gt;In our access logs we kept seeing the same User-Agents on tight loops:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GPTBot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ClaudeBot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Bytespider&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Amazonbot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Meta-ExternalAgent&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The peak we measured before blocking them was around &lt;strong&gt;15,000 requests per hour from a single bot&lt;/strong&gt;. On a sister project that took longer to get blocking right, we saw bursts close to 200k/day. None of these bots respect any kind of "please slow down". They either get a 200, a 429, or a 403 — pick one.&lt;/p&gt;

&lt;p&gt;We picked 403, eventually. But before we did, the public Open-Meteo API started returning 429s during peaks, and our pages started erroring out for real users.&lt;/p&gt;

&lt;p&gt;The math is brutal: 100 cities × 9 locales = 900 cacheable URLs. With a 60 minute cache TTL that's 900 origin requests per hour worst case for our own users. Add a single misbehaving crawler that ignores cache headers and asks for &lt;code&gt;?lat=…&amp;amp;lon=…&lt;/code&gt; with random rounding, and the cache hit rate collapses. We were burning through 10,000 calls/day in a few hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  We tried the paid tier first
&lt;/h2&gt;

&lt;p&gt;The Standard plan removes the per-minute, per-hour and per-day caps and gives you 1,000,000 calls per month on a &lt;code&gt;customer-api.open-meteo.com&lt;/code&gt; host. Switching is one env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;OPENMETEO_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/uv-api.ts (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;omHost&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;OPENMETEO_HOST&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;omHost&lt;/span&gt; &lt;span class="p"&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;OPENMETEO_API_KEY&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;CAMS_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;omHost&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;omHost&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/air-quality`&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://customer-air-quality-api.open-meteo.com/v1/air-quality&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="s1"&gt;https://air-quality-api.open-meteo.com/v1/air-quality&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open-Meteo are upfront in their FAQ that monthly limits aren't being enforced yet — they're still building the usage portal — so in practice the Standard plan is "soft 1M/month, dedicated servers, commercial use OK". This solved the rate-limit problem immediately.&lt;/p&gt;

&lt;p&gt;It did not solve two other things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Crawler load is wasteful spend.&lt;/strong&gt; Even if the limit isn't enforced, paying for traffic that produces no revenue (and no useful index entry on most platforms) is irritating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; Every page render fans out to two API hosts in another datacenter. We were measuring p50 around 180–220 ms per upstream call from our box. CAMS pollen + forecast = two of those, mostly serial.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Self-hosting Open-Meteo
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised us: it is genuinely easy.&lt;/p&gt;

&lt;p&gt;Open-Meteo publishes a single Docker image (&lt;code&gt;ghcr.io/open-meteo/open-meteo&lt;/code&gt;) that does both jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;serve&lt;/code&gt; — runs the API on port 8080. Same query syntax as the public API.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sync &amp;lt;model&amp;gt; &amp;lt;variables&amp;gt;&lt;/code&gt; — pulls the latest model run from the upstream provider (DWD, NOAA, ECMWF, MET Norway, …) and writes it to a shared volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You run one &lt;code&gt;serve&lt;/code&gt; and as many &lt;code&gt;sync&lt;/code&gt; workers as you have models you care about. Each &lt;code&gt;sync&lt;/code&gt; job re-runs on an interval (&lt;code&gt;--repeat-interval 5&lt;/code&gt; = every 5 minutes) and stores the last N days of past data (&lt;code&gt;--past-days 3&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;For us, the relevant compose file looks roughly like this:&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;open-meteo&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/open-meteo/open-meteo&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="nv"&gt;open-meteo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/app/data&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;expose&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;8080"&lt;/span&gt;&lt;span class="pi"&gt;]&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;serve"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;sync-dwd&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/open-meteo/open-meteo&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="nv"&gt;open-meteo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/app/data&lt;/span&gt;&lt;span class="pi"&gt;]&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;sync&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dwd_icon,dwd_icon_eu,dwd_icon_d2&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;temperature_2m,relative_humidity_2m,weather_code,cloud_cover,precipitation,shortwave_radiation&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--past-days=3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--repeat-interval=5&lt;/span&gt;

  &lt;span class="na"&gt;sync-cams&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/open-meteo/open-meteo&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="nv"&gt;open-meteo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/app/data&lt;/span&gt;&lt;span class="pi"&gt;]&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;sync&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cams_global,cams_europe&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;uv_index,uv_index_clear_sky,pm10,pm2_5,ozone,alder_pollen,birch_pollen,grass_pollen,ragweed_pollen&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--past-days=3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--repeat-interval=5&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;open-meteo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We sync six model groups in total:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DWD ICON&lt;/strong&gt; (11 km global, 7 km EU, 2 km Central EU)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NCEP&lt;/strong&gt; (GFS 13 km global, HRRR 3 km CONUS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ECMWF IFS&lt;/strong&gt; 25 km — long-range&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MET Norway / UKMO / BOM / CMC&lt;/strong&gt; for regional accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CAMS&lt;/strong&gt; global + Europe — UV, AQI, pollen&lt;/li&gt;
&lt;li&gt;A one-off &lt;code&gt;copernicus_dem90&lt;/code&gt; sync for elevation data (~10 GB, runs once)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Application-side change is one line: set &lt;code&gt;OPENMETEO_HOST=http://open-meteo:8080&lt;/code&gt; and the existing client code routes there instead of the public API. No query rewriting needed — that's the nice part of Open-Meteo's design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the box actually looks like
&lt;/h2&gt;

&lt;p&gt;Real numbers from a single VPS (8 vCPU, 16 GB RAM, 150 GB disk) running everything — three sites, Caddy, an IP-geo service, monitoring, and the full Open-Meteo stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk used by Open-Meteo data:&lt;/strong&gt; ~50 GB and stable. The DEM is the largest one-time cost (~10 GB). The rolling weather data stays bounded by &lt;code&gt;--past-days&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;open-meteo&lt;/code&gt; serve container at steady state:&lt;/strong&gt; ~1.1 GiB RAM, ~4 % of one core. Model files are mmapped, so the kernel page cache does most of the work — it's why &lt;code&gt;free -h&lt;/code&gt; shows ~13 GiB sitting in &lt;code&gt;buff/cache&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync workers:&lt;/strong&gt; burst CPU when a new model run lands (every 1–6 hours depending on model), idle the rest of the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initial sync:&lt;/strong&gt; 1–2 hours for the first run. This is the only painful step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a quieter footprint than we expected. Open-Meteo's storage format (&lt;a href="https://openmeteo.substack.com/" rel="noopener noreferrer"&gt;here's their write-up&lt;/a&gt;) is a custom layout designed for exactly this kind of mmap-friendly point lookups, and you can feel that in the metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency win
&lt;/h2&gt;

&lt;p&gt;We weren't optimising for this — we just wanted the rate limits gone — but it turned into the most visible result.&lt;/p&gt;

&lt;p&gt;Measured per-call upstream latency from our app container to Open-Meteo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public API (&lt;code&gt;api.open-meteo.com&lt;/code&gt;): ~100-110 ms&lt;/li&gt;
&lt;li&gt;Customer API (&lt;code&gt;customer-api.open-meteo.com&lt;/code&gt;): comparable, slightly more consistent&lt;/li&gt;
&lt;li&gt;Local container (&lt;code&gt;http://open-meteo:8080&lt;/code&gt;): ~10 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per page render that's &lt;strong&gt;roughly 90–100 ms shaved off&lt;/strong&gt;, twice (forecast + CAMS), most of it serial. For a server-rendered Next.js page that has to land HTML before the browser can paint, this is meaningful — we saw it directly in our TTFB numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we didn't migrate
&lt;/h2&gt;

&lt;p&gt;Not everything makes sense to host yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Geocoding&lt;/strong&gt; (&lt;code&gt;geocoding-api.open-meteo.com&lt;/code&gt;). It's a separate service with its own dataset; we kept it on the public API and put a 1-hour LRU cache in front of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical / climate / ensemble APIs.&lt;/strong&gt; We don't use them. If you do, note that the Standard plan also doesn't include them — that's a Professional-tier thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marine / flood APIs.&lt;/strong&gt; Same — out of scope for us.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gotchas
&lt;/h2&gt;

&lt;p&gt;A few things to know before you copy the docker-compose:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick variables deliberately.&lt;/strong&gt; Each &lt;code&gt;sync&lt;/code&gt; command takes an explicit list of variables. Adding a variable later means re-syncing — don't be too minimal at first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk growth is mostly the DEM.&lt;/strong&gt; The rolling weather data stays small &lt;em&gt;if&lt;/em&gt; &lt;code&gt;--past-days&lt;/code&gt; is small. Set this honestly — we use 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no built-in API key / rate limit on the local instance.&lt;/strong&gt; It binds to a private Docker network in our case; if you expose it to the internet, put a reverse proxy with auth or a rate limiter in front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crawlers will still hit your app.&lt;/strong&gt; Self-hosting Open-Meteo doesn't solve the crawler problem — it just stops the crawler problem from cascading into a third-party rate-limit problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution still applies.&lt;/strong&gt; Open-Meteo's data is CC BY 4.0; you keep crediting the underlying data sources (DWD, NOAA, ECMWF, CAMS, …) regardless of how you host it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;For our shape of traffic — small site, three domains sharing the same upstream, lots of automated traffic — yes, comfortably:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capacity:&lt;/strong&gt; effectively unbounded for our scale. We can let crawlers through if we ever change our mind without watching a meter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; ~10 ms per upstream call, twice per render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; one VPS that we already had, instead of a per-domain subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational risk:&lt;/strong&gt; lower than expected. The image is one container, the syncs are independent, and a failed sync just means stale-but-still-served data for that model.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Written by the team behind &lt;a href="https://uvi.today" rel="noopener noreferrer"&gt;uvi.today&lt;/a&gt;, &lt;a href="https://pollen.today" rel="noopener noreferrer"&gt;pollen.today&lt;/a&gt;, &lt;a href="https://simplemeteo.com" rel="noopener noreferrer"&gt;SimpleMeteo&lt;/a&gt; and &lt;a href="https://airindex.today" rel="noopener noreferrer"&gt;airindex.today&lt;/a&gt;. We post the engineering side on X — &lt;a href="https://x.com/SimpleMeteo" rel="noopener noreferrer"&gt;@SimpleMeteo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
