In the previous post , we successfully set up a PostgreSQL server with all the necessary configurations for OpenStreetMap data. Now it’s time to take the next step and start building a working tile server.

The base overv/openstreetmap-tile-server Docker image is designed to work with a single data source - be it a continent, country, or even a part of a country. While this works well for many use cases, I needed something different: the ability to work with multiple selected countries simultaneously. Let me walk you through my first attempt to reach that goal, which, while ultimately unsuccessful, provided valuable lessons.

Cinode maps multi-region setup

The initial multi-region approach

With a working PostgreSQL server in place, my first thought was to create a setup where each region would have its own synchronizer component but shared database. This seemed like a logical approach - each region could independently manage its data updates without interfering with others.

Such approach would also be consistent with the base Docker image’s design, just extended to handle multiple regions. Converting it to a Kubernetes deployment seemed like a straightforward task. This would allow us to test the idea without investing too much time in custom development.

Here’s a slightly reduced deployment file (and here is the link to the full version):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{{- range $.Values.regions }}
{{- if .enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ print $.Release.Name "-region-sync-" .name | quote }}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: {{ print $.Release.Name "-region-sync-" .name | quote }}
  template:
    metadata:
      labels:
        app: {{ print $.Release.Name "-region-sync-" .name | quote }}
        region: {{ .name | quote }}
    spec:
      containers:
        - name: {{ print "region-sync-init-" .name | quote }}
          image: {{ printf "%s/%s:%s" $.Values.tileServer.image.registry $.Values.tileServer.image.repository $.Values.tileServer.image.tag | quote }}
          env:
            # ... (env configuration for db and download URls)
          volumeMounts:
            - name: state
              mountPath: /data
              subPath: {{ print "region/" .name | quote }}
            # Some more temporary volumes
          command:
            - sh
            - -c
            - |
              # ... a startup script extracted / converted from the original docker image              

      volumes:
        - name: state
          persistentVolumeClaim:
            claimName: {{ print $.Release.Name "-tile-server-data" | quote }}
        # ... some more volumes, mostly temporary ones
{{- end }}
{{- end }}

Configuring regions in the Helm chart

The Helm chart’s values file turned out to be a perfect place to configure regions. For each region, we needed:

  • A unique name
  • A download URL for the region’s data
  • A download URL for the poly file defining the region’s boundaries
  • An enabled flag for better flexibility
  • A switch indicating whether this is an initial import or extension of existing data (more on that flag later)

Here’s an example configuration I used during the initial phase of my tests:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
regions:
  - enabled: true
    name: europe-poland-dolnoslaskie
    pbf: https://download.geofabrik.de/europe/poland/dolnoslaskie-latest.osm.pbf
    poly: https://download.geofabrik.de/europe/poland/dolnoslaskie.poly

  - enabled: true
    initial: true
    name: europe-germany-sachsen
    pbf: https://download.geofabrik.de/europe/germany/sachsen-latest.osm.pbf
    poly: https://download.geofabrik.de/europe/germany/sachsen.poly

Selected regions are relatively small and can be quickly tested. I also selected regions that could potentially overlap to see whether the system can handle such a case.

Setting up the synchronizer

The next step was to create a synchronizer component that would handle the data import for each region. I converted the original script into a custom one. The final script has the following responsibilities:

  • Detect whether the import was already done - and if so, skip data import and go straight to the synchronization loop
  • Synchronize recent changes by fetching and applying changes directly from the openstreetmap servers

Let me go through some challenges I had to face.

Minimal effort approach

To keep things simple, I decided to embed the script directly in the Kubernetes manifest rather than creating a new Docker image or using ConfigMaps. This approach allowed for quick iterations and testing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
---
apiVersion: apps/v1
kind: Deployment
# ...
spec:
  # ...
  template:
    # ...
    spec:
      containers:
        - name: {{ print "region-sync-init-" .name | quote }}
          # ...
          command:
            - sh
            - -c
            - |
              set -eu
              set -x
              # ... script content here              

PostgreSQL password handling

The original OpenStreetMap synchronization script didn’t handle PostgreSQL passwords in a way that worked with our setup. A small modification was needed to the update script:

1
2
3
4
5
6
7
8
# Small fix to the update script to use pgdb password
cp $(which openstreetmap-tiles-update-expire.sh) /tmp/otue.sh
sed -i 's/^TRIM_OPTIONS=.*$/TRIM_OPTIONS="-d $DBNAME --user $PGUSER --password"/g' /tmp/otue.sh

# ...

# initial setup of osmosis workspace (for consecutive updates)
/tmp/otue.sh $REPLICATION_TIMESTAMP || true

Adopting tools for kubernetes environment

Working with OpenStreetMap tools in a Kubernetes environment presented some unexpected challenges, particularly with logging. The osm2pgsql tool, which handles the data import, uses terminal-specific progress reporting that doesn’t work well in Kubernetes logs. It uses carriage returns (\r) to update progress information in place, which is perfect for terminal displays but problematic for log viewing tools that process logs line-by-line.

This caused the import process to appear stuck in Kubernetes logs, as the progress updates weren’t being displayed properly. The solution was to modify the output handling:

1
2
3
osm2pgsql -d gis \
  # Some more options here ... 
2>&1 | stdbuf -o0 tr '\r' '\n';

This command:

  1. Redirects both standard output and error (2>&1)
  2. Uses stdbuf -o0 to disable output buffering and delayed display for the tr command
  3. Translates carriage returns (\r) to newlines (\n) using tr

The result is a more readable log stream that works well with Kubernetes log viewers and other log processing tools. Each progress update appears on a new line, making it easier to track the import progress and diagnose any issues that might arise.

SQL indexes for map styles

Another challenge was applying the necessary SQL indexes for map styles. This required proper PostgreSQL user configuration and environment variables:

1
2
3
4
# Create indexes
if [ -f "/data/style/${NAME_SQL:-indexes.sql}" ]; then
  psql -d gis -f "/data/style/${NAME_SQL:-indexes.sql}"
fi

The environment variables were configured in the deployment:

1
2
3
4
5
6
7
8
9
env:
  - name: PGHOST
    value: {{ print $.Release.Name "-postgres" | quote }}
  - name: PGDATABASE
    value: {{ $.Values.postgres.auth.database | quote }}
  - name: PGUSER
    value: {{ $.Values.postgres.auth.username | quote }}
  - name: PGPASSWORD
    value: {{ $.Values.postgres.auth.password | quote }}

Periodic synchronization

Instead of using a cronjob, I added a simple loop for periodic synchronization:

1
2
3
4
while true; do
  sh /tmp/otue.sh &
  sleep 60
done

Initial vs subsequent imports

A crucial realization came after a few initial attempts: there’s a significant difference between setting up a new database from scratch and adding a new region to an existing database. The original script always started from scratch, wiping the database clean. This meant that importing new regions would completely remove data from existing ones.

To support multiple regions, I needed a way to import new data without wiping out existing data. This led to the introduction of the initial flag in the values file. This flag must only be set for one region, and when set, that region’s data import will perform a from-scratch initialization of the database.

The import script handles this with the following logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Import data
osm2pgsql -d gis \
  {{ if .initial | default false }} --create {{ else }} --append {{ end }} \
  --slim -G --hstore  \
  --tag-transform-script /data/style/${NAME_LUA:-openstreetmap-carto.lua}  \
  --number-processes ${THREADS:-4}  \
  -S /data/style/${NAME_STYLE:-openstreetmap-carto.style}  \
  /data/download/region.osm.pbf  \
  ${OSM2PGSQL_EXTRA_ARGS:-}  \
2>&1 | stdbuf -o0 tr '\r' '\n';

The osm2pgsql tool uses the --create flag for the first region to set up the database and import initial data common to all imports. For subsequent regions, the --append flag is used to extend the existing dataset.

This approach does have some operational inconveniences. The proper sequence for importing multi-region data is:

  1. Disable all regions except the initial one
  2. Set the initial flag to true for the first region
  3. Install the Helm chart and wait for the initial import to complete
  4. Enable other regions one-by-one, waiting for each to complete its initial import

However, at this stage of the project, such operational complexities were less important. The primary goal was to validate whether the multi-region concept would work at all, making the least-effort approach the most appropriate.

The first signs of trouble

Initially, everything seemed to work well. The first region - Sachsen in Germany was quickly imported. After turning on another region - Dolnośląskie in Poland, the import was successful as well but there was a noticeable performance impact.

Here’s a quick insight of the speed difference:

  • For Sachsen: Processing: Node(22547k 563.7k/s) Way(3577k 29.32k/s) Relation(49080 2045.0/s)
  • For Dolnośląskie: Processing: Node(17905k 3.5k/s) Way(2394k 2.13k/s) Relation(20820 408.2/s)

The slowdown is more than 100x for node import, 10x for way import and 5x for relation import. Despite such a drastic slowdown, I thought it might be worth trying on larger regions, so I switched over to whole countries: Germany and Poland.

Importing whole countries was obviously much slower than importing small parts of countries. The initial import for Germany took more than 7 hours. Judging by the slowdown with subsequent regions from the previous attempt with smaller datasets, I could already sense that this method wouldn’t be practical, but since we’d come this far, let’s see how adding Poland would work.

Adding a second country quickly revealed serious issue though. Not observed with smaller regions, new dataset seems to reveal colliding parts of the map. In such case the data import simply failed:

1
2
3
2025-04-24 07:04:30  ERROR: DB copy thread failed: Ending COPY mode for 'planet_osm_nodes' failed: ERROR:  duplicate key value violates unique constraint "planet_osm_nodes_pkey"
DETAIL:  Key (id)=(2875733821) already exists.
CONTEXT:  COPY planet_osm_nodes, line 97286

That was the final argument to abandon this approach.

I’m attaching full logs made during import for reference:

The research that followed

After encountering these issues, I did some research and discovered that this problem was already well-known in the OpenStreetMap community:

This was a valuable lesson: always search the Internet first before diving into implementation. The community had already documented these challenges and potential solutions.

Conclusion

While this first attempt at a multi-region setup didn’t succeed, it provided important insights:

  1. Performance degradation: The approach of using a single import process per region showed significant performance degradation, especially when importing subsequent regions. The slowdown was more than 100x for node imports, making it impractical for larger datasets.

  2. Data conflicts: The attempt to merge multiple regions revealed fundamental issues with data conflicts. The unique constraints in the database prevented successful merging of overlapping regions, as the same nodes could exist in multiple datasets.

  3. Operational complexity: Even if the technical challenges were overcome, the operational complexity of managing multiple regions through separate import and synchronization processes would be significant. The need to carefully sequence imports and manage the initial flag adds unnecessary complexity to the deployment process.

  4. Community knowledge: This experience reinforced the importance of researching existing solutions and community knowledge before implementing complex features. The OpenStreetMap community had already documented these challenges and potential solutions.

In the next post, I’ll explore alternative approaches to achieve our multi-region goal, taking into account the lessons learned from this failed attempt. Stay tuned!