Setting up geoserver with docker and postgis, connecting to WFS with OpenLayers

A free screencast (video) course is available for this post but you need to be signed in order to view it, you can sign in here if you already have an account or register here if you don't have one.

We are now serving PostGIS stored data through OpenLayers as vector features. Still, we have a few problems in our design, one of them being that we are loading the features synchronously, passing them to the OpenLayers component as an argument in the Html itself. If we scale to hundreds, potentially thousands of features, the page size will overgrow and not be responsive until all data is finished loading in the browser. Using WFS (Web Feature Service) services in Geoserver will fix this problem as we will load our features asynchronously with OpenLayers.

  • Let's add a Geoserver service to our docker-compose.yml file and a volume, so we persist the data directory (make sure your services are all down first by running "docker-compose down" before saving the changes):
version: "3.7"
volumes:
    postgres-data:
    redis-data:
# The volume for persisting geoserver data directory
    geoserver-data:
networks:
    frontend:
    backend:
services:
    proxy:
        image: nginx:latest
        ports:
            - "8080:80"
        volumes:
            - ./:/var/www/app
            - ./docker/nginx/nginx-site.conf:/etc/nginx/conf.d/default.conf
        networks:
            - frontend
            - backend
    php:
        build:
            context: ./docker/php
            dockerfile: Dockerfile
        image: laravelgis-php:latest
        ports:
            - "5173:5173"
        volumes:
            - ./:/var/www/app
        networks:
            - backend
    postgres:
        image: postgis/postgis:15-3.3
        volumes:
            - postgres-data:/var/lib/postgresql/data
        ports:
            - "5432:5432"
        environment:
            POSTGRES_PASSWORD: 12345
            POSTGRES_USER: laravelgis
            POSTGRES_DB: laravelgis
            PGDATA: /var/lib/postgresql/data
        networks:
            - backend
    redis:
        image: redis:latest
        sysctls:
            - net.core.somaxconn=511
        ports:
            - "6379:6379"
        volumes:
            - redis-data:/data
        networks:
            - backend
# We use a well maintained and widely used docker image for geoserver: kartoza/geoserver.
# Geoserver uses port 8080 by default so we map our host port 8081 to
# the container's 8080 port, map the volume and set up a few environment variables
     geoserver:
         image: kartoza/geoserver:latest
         ports:
             - "8081:8080"
         volumes:
             - geoserver-data:/opt/geoserver/data_dir
         environment:
             GEOSERVER_DATA_DIR: /opt/geoserver/data_dir
             GEOSERVER_ADMIN_USER: admin
             GEOSERVER_ADMIN_PASSWORD: geoserver
         networks:
             - backend						

After running "docker-compose up" again, the latest kartoza/geoserver docker image should be automatically downloaded, and you should be able to point your browser to http://localhost:8081/geoserver and see a page like this:

Setting up geoserver with Docker We can log in using the credentials specified in the docker-compose.yml file (admin/geoserver in our case). At this point, we have a plain empty Geoserver; we will first need to create a workspace for our project. Once logged in, click "Workspaces" in the menu on the left and the "Add new Workspace" link at the top of the page. Create a new workspace named laravelgis and set it as the default workspace like this:

Setting up geoserver with Docker Once we have a workspace, we will be able to create a PostGIS Store; this will be the direct connection between Geoserver and PostGIS. Click "Stores" in the menu on the left and the "Add new Store" link at the top. In the next screen, choose "PostGIS - PostGIS Database" from the "Vector Data Sources" section. Then, fill the database connection information exactly as it is set in the .env file:

Setting up geoserver with Docker You will be presented with the option to add a new layer from the new PostGIS Store; click publish on the monuments table:

Setting up geoserver with Docker In the next page, leave everything as it is with the default values but click on "Compute from SRS Bounds" in the "Native Bounding Box" section and on "Compute from Native Bounds" in the "Lat/Lon Bounding Box" section before saving.

Setting up geoserver with Docker Great, that's all we need to do in terms of Geoserver setup for now; the layer is now shared by Geoserver:

Setting up geoserver with Docker Let's see how we can asynchronously load it directly from OpenLayers. First, remove the ugly Laravel code in the routes/web.php file:

<?php

use App\Models\Monument;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::middleware([
    'auth:sanctum',
    config('jetstream.auth_session'),
    'verified'
    $geojson = [
        'type' => 'FeatureCollection',
        'features' => [],
    ];

    Monument::selectRaw('id, name, image, ST_AsGeoJSON(geom) as geom')
        ->get()
        ->each(function ($monument) use (&$geojson) {
            $geojson['features'][] = [
                'type' => 'Feature',
                'properties' => [
                    'name' => $monument->name,
                    'image' => $monument->image,
                ],
                'geometry' => json_decode($monument->geom, true),
            ];
        });

    return view('dashboard', ['geojson' => json_encode($geojson)]);
    return view('dashboard');
})->name('dashboard');

In the resources/views/components/map.blade.php file, let's make the following changes:

@props(['monuments'])
<div x-data="map()" x-init="initComponent({{ json_encode($monuments) }})">
<div x-data="map()" x-init="initComponent()">
	<div x-ref="map" class="relative h-[600px] overflow-clip rounded-md border border-slate-300 shadow-lg">
(...)
                <ul class="mt-2 space-y-1 rounded-md border border-slate-300 bg-white p-2">
                    <template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
                        <li class="flex items-center px-2 py-1">
                            <div x-id="['legend-checkbox']">
                                <label x-bind:for="$id('legend-checkbox')" class="flex items-center">
                                    <input type="checkbox" x-bind:checked="layer.getVisible()"
                                           x-bind:id="$id('legend-checkbox')"
                                           x-on:change="layer.setVisible(!layer.getVisible())"
                                           class="rounded border-slate-300 text-[#3369A1] shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
                                    <span class="ml-2 text-sm text-slate-600" x-text="layer.get('label')"></span>
                                </label>
                                <template x-if="layer.get('label') === 'Monuments' && layer.getVisible()">
                                    <div class="mt-2 ml-6 text-sm text-slate-600">
                                    <template x-for="(feature, index) in layer.getSource().getFeatures()" :key="index">
                                        <a href="#"
                                            :title="'Go to ' +  feature.get('name')"
                                            x-text="feature.get('name')"
                                            x-on:click.prevent="gotoFeature(feature)"
                                            class="block hover:underline hover:text-slate-800 focus:outline-none focus:underline focus:text-slate-800 transition">
                                        </a>
                                    </template>
                                     <!-- As the monuments are loading asynchronously, we will setup a getter on the
                                     component so Alpine will pick up on changes and trigger the legend refresh -->
                                    <template x-for="(feature, index) in monumentsFeatures" :key="index">
                                        <a href="#" :title="'Go to ' +  feature.get('name')"
                                            x-text="feature.get('name')" x-on:click.prevent="gotoFeature(feature)"
                                            class="block transition hover:text-slate-800 hover:underline focus:text-slate-800 focus:underline focus:outline-none">
                                        </a>
                                    </template>
                                    </div>
                                </template>
                            </div>
                        </li>
                    </template>
                </ul>
(...)

In the resources/views/dashboard.blade.php, remove the monuments parameter passed to the map component as we don't need it anymore:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>
    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
                <x-map :monuments="$geojson"/>
                <x-map />							
            </div>
        </div>
    </div>
</x-app-layout>

Finally, make the following changes to the resources/js/components/map.js:

import Map from "ol/Map.js";
import View from "ol/View.js";
import TileLayer from "ol/layer/Tile.js";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import OSM from "ol/source/OSM.js";
import { Style, Fill, Stroke, Circle, Text } from "ol/style.js";
import GeoJSON from "ol/format/GeoJSON";

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            features: [],
            monumentsLayer: [],
// No need to receive the monuments anymore
            initComponent(monuments) {
            initComponent() {
                this.features = new GeoJSON().readFeatures(monuments);
// WFS is a REST service so we configure the query string parameters first
// We use the GetFeature request type on the laravelgis:monuments layer
                const paramsObj = {
                    servive: 'WFS',
                    version: '2.0.0',
                    request: 'GetFeature',
                    typeName: 'laravelgis:monuments',
                    outputFormat: 'application/json',
                    crs: 'EPSG:4326',
                    srsName: 'EPSG:4326',
                }

                const urlParams = new URLSearchParams(paramsObj)
// Adds the query string to the base geoserver WFS url
                const monumentsUrl = 'http://localhost:8081/geoserver/wfs?' +  urlParams.toString()

// Creates the layer with a vector source of format get, with the url we just generated and with
// the same styleFunction we've been using.
                this.monumentsLayer = new VectorLayer({
                    source: new VectorSource({
                        format: new GeoJSON(),
                        url: monumentsUrl,
                    }),
                    style: this.styleFunction,
                    label: "Monuments",
                })
								
                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: "OpenStreetMap",
                        }),
                        new VectorLayer({
                            source: new VectorSource({
                                features: this.features,
                            }),
                            style: this.styleFunction,
                            label: "Monuments",
                        }),
// Use the new monumentsLayer
                        this.monumentsLayer,
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [0, 0],
                        zoom: 2,
                    }),
                });
            },
// Getter on the monuments features, used in the legend
            get monumentsFeatures() {
                return this.monumentsLayer.getSource().getFeatures()
            },						
            styleFunction(feature, resolution) {
                return new Style({
                    image: new Circle({
                        radius: 4,
                        fill: new Fill({
                            color: "rgba(0, 255, 255, 1)",
                        }),
                        stroke: new Stroke({
                            color: "rgba(192, 192, 192, 1)",
                            width: 2,
                        }),
                    }),
                    text: new Text({
                        font: "12px sans-serif",
                        textAlign: "left",
                        text: feature.get("name"),
                        offsetY: -15,
                        offsetX: 5,
                        backgroundFill: new Fill({
                            color: "rgba(255, 255, 255, 0.5)",
                        }),
                        backgroundStroke: new Stroke({
                            color: "rgba(227, 227, 227, 1)",
                        }),
                        padding: [5, 2, 2, 5],
                    }),
                });
            },
            gotoFeature(feature) {
                this.map.getView().animate({
                    center: feature.getGeometry().getCoordinates(),
                    zoom: 10,
                    duration: 2000,
                });
            },
        };
    });
});

If we did everything correctly, run "dr npm run dev" and go to http://localhost:8080/dashboard, we should still see our features on the map, but we are now using the Geoserver WFS as a data source for the monuments layers. In the image below, you can see the response from the WFS service in json:

Setting up geoserver with Docker

In the next post, we will secure our Geoserver a little bit and prevent future complicated debugging CORS problems by taking advantage of our Nginx proxy service and set up a reverse proxy for Geoserver.

The commit for this post is available here: setting-up-geoserver-with-docker

First published 2 years ago
Latest update 1 year ago
No comment has been posted yet, start the conversation by posting a comment below.
You need to be signed in to post comments, you can sign in here if you already have an account or register here if you don't.