Here is a tutorial to to create a geospatial app using Wagtail (alongside Geodjango) and PostgreSQL extension PostGIS so that you can create objects using latitude/longitude coordinates or the map inside the admin site.

Code and folder hierarchy available on GitHub.

Create a directory for the whole project: mkdir mysite_whole

Create a virtual environment as no to pollute your local machine with version control issues, that way we can peacefully install Django locally. virtualenv -p python3 venv
We are making a virtual environment using Python 3 which will be stored inside venv folder.
Let’s activate it:
source venv/bin/activate

Then we install Wagtail CMS, Django is also part of the installation process: pip install wagtail

Create our Wagtail site’s folder then let Wagtail generate the site:

  mkdir mysite
  wagtail start mysite mysite

Install the dependencies making sure you already have everything to run Wagtail:

  cd mysite
  pip install -r requirements.txt

Locally we may only need to use SQLite, at first anyway, so we need to match our models developed inside Django/Wagtail to our database. To do so, run the following command: id:: 68877b48-32a3-4b93-b6ef-87a3a10ae32a python manage.py migrate
This command will be extremely important as you make changes to your models and need to update your dev/production site running inside a Docker container so that restart the container, Django will know to make database migrations.

Now onto creating an admin user so we can run, serve and manage our website: python manage.py createsuperuser
Enter a username, email address and password.
Launch your site:
python manage.py runserver
You can check it out at: http://127.0.0.1:8000/

You website is functional albeit only locally. :logbook: CLOCK: [2025-08-04 Mon 10:18:05] CLOCK: [2025-08-04 Mon 10:18:07] :END: A Dockerfile will allow us to run our installation inside a virtual container. And you might notice a file named Dockerfile already exists inside mysite folder. Perfect! We can already server our website using the following commands.
First we build our container named easy_django_wagtail:
docker build . -t mysite/backend
Then we spin up the container and hook it on port 8000 so it is accessible through this port as specified in the Dockerfile.
docker run -p 8001:8000 mysite/backend

In order to create a super user to log into the admin interface, you first need to know the ID of your wagtail container. docker container ps
Should display this table:

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
container_id easy_django_wagtail_test “/bin/sh -c ‘set -xe…” 2 minutes ago Up 2 minutes 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp random_name

Then:
docker exec -it container_id python manage.py createsuperuser
Write your username, email address and password.
To shut it off:
docker stop container_id

We could use it as such, changing ports to match port 80; Let’s Encrypt even has six-day SSL certificates for IP addresses.

For a better running Wagtail install, we are going to add a few modifications to our project. We will use Caddy to serve our site through a reverse proxy and get a SSL certificate for our domain with Let’s Encrypt in a future article. Right now we are looking to run the overall project with a Docker Compose instance, so that database migrations to follow model changes and local tests can easily be done using our local virtual environment and python manage.py commands.

Running the project with Docker Compose Link to heading

An entrypoint will help us move from a dev to a production setting, inside mysite/: nano docker-entrypoint.sh

  #!/bin/sh
  # docker-entrypoint.sh
  
  if test -f "$FILE"; then
    echo 'Start removed manage added prod'
    rm manage.py && mv manage.production.py manage.py
    rm mysite/wsgi.py && mv mysite/wsgi.production.py mysite/wsgi.py
  fi
  
  python manage.py makemigrations --noinput
  python manage.py migrate --noinput
  python manage.py collectstatic --noinput
  # Launch the main container command passed as arguments.
  exec "$@"

Make the entrypoint executable:
chmod +x docker-entrypoint.sh
Add the command to your Dockerfile:
ENTRYPOINT ["./docker-entrypoint.sh"]
And remove commands now deemed unnecessary:

  RUN python manage.py collectstatic --noinput --clear
  # And
  CMD set -xe; python manage.py migrate --noinput; gunicorn mysite.wsgi:application

We create a start.sh file that will run our project in production setting when needed; to do so, the executable will replace our base manage.py and wsgi.py with manage.production.py which will tell Django to use mysite/mysite/production.py and wsgi.production.py respectively. For now though, we’ll set manage.production.py as *.pynone extensions so as not to trigger the boolean condition to set our environment as production. start.sh

  FILE=manage.production.py
  if test -f "$FILE"; then
  echo 'Start removed manage added prod'
  rm manage.py && mv manage.production.py manage.py
  rm mysite/wsgi.py && mv mysite/wsgi.production.py mysite/wsgi.py
  fi
  python manage.py makemigrations --noinput
  python manage.py migrate --noinput
  python manage.py collectstatic --noinput
  gunicorn mysite.wsgi:application

Then create manage.production.pynone:

  #!/usr/bin/env python
  import os
  import sys
  
  if __name__ == "__main__":
      os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.production")
  
      from django.core.management import execute_from_command_line
  
      execute_from_command_line(sys.argv)

And finally mysite/wsgi.production.py:

  """
  WSGI config for trashitcd  project.
  
  It exposes the WSGI callable as a module-level variable named ``application``.
  
  For more information on this file, see
  https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
  """
  
  import os
  
  from django.core.wsgi import get_wsgi_application
  
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings.production")
  
  application = get_wsgi_application()

Create a docker-compose.yml file in the root directory mysite_whole of your install:

  services:
    backend:
      build:
        context: mysite/
      restart: always
      image: mysite/backend
      ports:
        - "800:8000"
      entrypoint: ["/bin/sh","-c"]
      command:
      - |
         ./start.sh
      networks:
        - mysite-backend
      volumes:
        - static:/app/static
        - media:/app/media
        - private:/app/private
  networks:
    mysite-backend:
      driver: bridge
  volumes:
      static:
        driver: local
      media:
        driver: local
      private:
        driver: local
  

Build your Dockerfile project: docker build . -t mysite/backend
And then launch Docker Compose with:
docker compose up
You will have to recreate your admin login this time using Docker Compose:
docker compose exec backend python manage.py createsuperuser

Go to 127.0.0.1:8001, login, then Snippets and add a location object and you either enter latitude/longitude coordinates or drag the pin to the desired location.

Using Postgres-based Postgis geospatial database Link to heading

We are going ahead with preparing our site to use a geospatial database: PostGIS. First, inside Dockerfile make a few changes:
nano Dockerfile

Add the following to system packages:

      gdal-bin \
      libgdal-dev \
      python3-gdal \

So Dockerfile should look like this:

  # Use an official Python runtime based on Debian 12 "bookworm" as a parent image.
  FROM python:3.12-slim-bookworm
  
  # Add user that will be used in the container.
  RUN useradd wagtail
  
  # Port used by this container to serve HTTP.
  EXPOSE 8000
  
  # Set environment variables.
  # 1. Force Python stdout and stderr streams to be unbuffered.
  # 2. Set PORT variable that is used by Gunicorn. This should match "EXPOSE"
  #    command.
  ENV PYTHONUNBUFFERED=1 \
      PORT=8000
  
  # Install system packages required by Wagtail and Django.
  RUN apt-get update --yes --quiet && apt-get install --yes --quiet --no-install-recommends \
      build-essential \
      libpq-dev \
      libmariadb-dev \
      libjpeg62-turbo-dev \
      zlib1g-dev \
      libwebp-dev \
      gdal-bin \
      libgdal-dev \
      python3-gdal \
   && rm -rf /var/lib/apt/lists/*
  
  # Install the application server.
  RUN pip install "gunicorn==20.0.4"
  
  # Install the project requirements.
  COPY requirements.txt /
  RUN pip install -r /requirements.txt
  
  # Use /app folder as a directory where the source code is stored.
  WORKDIR /app
  
  # Set this directory to be owned by the "wagtail" user. This Wagtail project
  # uses SQLite, the folder needs to be owned by the user that
  # will be writing to the database file.
  RUN chown wagtail:wagtail /app
  
  # Copy the source code of the project into the container.
  COPY --chown=wagtail:wagtail . .
  
  # Use user "wagtail" to run the build commands below and the server itself.
  USER wagtail
  
  ENTRYPOINT ["./docker-entrypoint.sh"]             # must be JSON-array syntax

It is also necessary to add psycopg2 module to requirements.txt which has been created during our initial wagtail site install in order to use the PostGIS database. We take this opportunity to add wagtaileowidget, will help us display a map field when managing objects inside the admin site:

  psycopg2
  wagtailgeowidget==8.2.1

In order to install future PiP packages we need to comment Psycopg so that it doesn’t bother us when we install packages specified in requirements.txt in our virtual environment for migrations as we use SQLite locally. Copy your requirements.txt to requirements-local.txt and comment psycopg2 line like so:

Django>=5.2,<5.3
wagtail>=7.0,<7.1
#psycopg2
wagtailgeowidget==8.2.1

Then all you need to do is install requirements using: pip install -r requirements-local.txt

In the file mysite/mysite/settings/base.py, add modules django.contrib.gis and wagtailgeowidget to INSTALLED_APPS so it should look like this:

  INSTALLED_APPS = [
      "home",
      "search",
      "wagtail.contrib.forms",
      "wagtail.contrib.redirects",
      "wagtail.embeds",
      "wagtail.sites",
      "wagtail.users",
      "wagtail.snippets",
      "wagtail.documents",
      "wagtail.images",
      "wagtail.search",
      "wagtail.admin",
      "wagtail",
      "modelcluster",
      "taggit",
      "django_filters",
      "django.contrib.admin",
      "django.contrib.auth",
      "django.contrib.contenttypes",
      "django.contrib.sessions",
      "django.contrib.messages",
      "django.contrib.staticfiles",
      "django.contrib.gis",
      "wagtailgeowidget",
  ]

Inside mysite/mysite/settings/dev.py, tell Django/Wagtail set the default database parameters as environment variables to switch between SQLite and PostgreSQL databases. We also include Wagtail admin base url:

  from .base import *
  
  DEBUG = True
  
  # SECURITY WARNING: keep the secret key used in production secret!
  SECRET_KEY = "django-insecure-secret-key"
  
  # SECURITY WARNING: define the correct hosts in production!
  ALLOWED_HOSTS = ["*"]
  
  EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
  
  DATABASES = {
      'default': {
          'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
          'NAME': os.environ.get('POSTGRES_DB', 'db.sqlite3'),
          'USER': os.environ.get('POSTGRES_USER', 'dbuser'),
          'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'dbpasswd'),
          'HOST': 'db',
          'PORT': '5433',
      }
  }
  
  WAGTAILADMIN_BASE_URL = os.environ.get('WAGTAILADMIN_BASE_URL', '127.0.0.1')
  
  try:
      from .local import *
  except ImportError:
      pass
  

We need to create a file for our backend service to wait for the database to be ready, wait-for-it.sh, then start it by executing start.sh. wait-for-it.sh

  #!/usr/bin/env bash
  # Use this script to test if a given TCP host/port are available
  
  WAITFORIT_cmdname=${0##*/}
  
  echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
  
  usage()
  {
      cat << USAGE >&2
  Usage:
      $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
      -h HOST | --host=HOST       Host or IP under test
      -p PORT | --port=PORT       TCP port under test
                                  Alternatively, you specify the host and port as host:port
      -s | --strict               Only execute subcommand if the test succeeds
      -q | --quiet                Don't output any status messages
      -t TIMEOUT | --timeout=TIMEOUT
                                  Timeout in seconds, zero for no timeout
      -- COMMAND ARGS             Execute command with args after the test finishes
  USAGE
      exit 1
  }
  
  wait_for()
  {
      if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
          echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
      else
          echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
      fi
      WAITFORIT_start_ts=$(date +%s)
      while :
      do
          if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
              nc -z $WAITFORIT_HOST $WAITFORIT_PORT
              WAITFORIT_result=$?
          else
              (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
              WAITFORIT_result=$?
          fi
          if [[ $WAITFORIT_result -eq 0 ]]; then
              WAITFORIT_end_ts=$(date +%s)
              echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
              break
          fi
          sleep 1
      done
      return $WAITFORIT_result
  }
  
  wait_for_wrapper()
  {
      # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
      if [[ $WAITFORIT_QUIET -eq 1 ]]; then
          timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
      else
          timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
      fi
      WAITFORIT_PID=$!
      trap "kill -INT -$WAITFORIT_PID" INT
      wait $WAITFORIT_PID
      WAITFORIT_RESULT=$?
      if [[ $WAITFORIT_RESULT -ne 0 ]]; then
          echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
      fi
      return $WAITFORIT_RESULT
  }
  
  # process arguments
  while [[ $# -gt 0 ]]
  do
      case "$1" in
          *:* )
          WAITFORIT_hostport=(${1//:/ })
          WAITFORIT_HOST=${WAITFORIT_hostport[0]}
          WAITFORIT_PORT=${WAITFORIT_hostport[1]}
          shift 1
          ;;
          --child)
          WAITFORIT_CHILD=1
          shift 1
          ;;
          -q | --quiet)
          WAITFORIT_QUIET=1
          shift 1
          ;;
          -s | --strict)
          WAITFORIT_STRICT=1
          shift 1
          ;;
          -h)
          WAITFORIT_HOST="$2"
          if [[ $WAITFORIT_HOST == "" ]]; then break; fi
          shift 2
          ;;
          --host=*)
          WAITFORIT_HOST="${1#*=}"
          shift 1
          ;;
          -p)
          WAITFORIT_PORT="$2"
          if [[ $WAITFORIT_PORT == "" ]]; then break; fi
          shift 2
          ;;
          --port=*)
          WAITFORIT_PORT="${1#*=}"
          shift 1
          ;;
          -t)
          WAITFORIT_TIMEOUT="$2"
          if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
          shift 2
          ;;
          --timeout=*)
          WAITFORIT_TIMEOUT="${1#*=}"
          shift 1
          ;;
          --)
          shift
          WAITFORIT_CLI=("$@")
          break
          ;;
          --help)
          usage
          ;;
          *)
          echoerr "Unknown argument: $1"
          usage
          ;;
      esac
  done
  
  if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
      echoerr "Error: you need to provide a host and port to test."
      usage
  fi
  
  WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
  WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
  WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
  WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
  
  # Check to see if timeout is from busybox?
  WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
  WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
  
  WAITFORIT_BUSYTIMEFLAG=""
  if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
      WAITFORIT_ISBUSY=1
      # Check if busybox timeout uses -t flag
      # (recent Alpine versions don't support -t anymore)
      if timeout &>/dev/stdout | grep -q -e '-t '; then
          WAITFORIT_BUSYTIMEFLAG="-t"
      fi
  else
      WAITFORIT_ISBUSY=0
  fi
  
  if [[ $WAITFORIT_CHILD -gt 0 ]]; then
      wait_for
      WAITFORIT_RESULT=$?
      exit $WAITFORIT_RESULT
  else
      if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
          wait_for_wrapper
          WAITFORIT_RESULT=$?
      else
          wait_for
          WAITFORIT_RESULT=$?
      fi
  fi
  
  if [[ $WAITFORIT_CLI != "" ]]; then
      if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
          echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
          exit $WAITFORIT_RESULT
      fi
      exec "${WAITFORIT_CLI[@]}"
  else
      exit $WAITFORIT_RESULT
  fi

Again, make the file executable:
chmod +x wait-for-it.sh

Now we prepare docker-compose.yaml, adding our Postgres database service, and filling in environment variables used both by PostgrSQL/PostGIS and Django/Wagtail. We’ll later see that environment variables set in an .env file will take precedence over the default ones when setting up our project to be production-ready.

  services:
    backend:
      build:
        context: mysite/
      restart: always
      image: mysite/backend
      ports:
        - "8001:8000"
      entrypoint: ["/bin/sh","-c"]
      command:
      - |
         ./wait-for-it.sh db:5433 -- ./start.sh
      environment:
        - DB_ENGINE=${DB_ENGINE:-django.contrib.gis.db.backends.postgis}
        - POSTGRES_DB=${POSTGRES_DB:-dbname}
        - POSTGRES_USER=${POSTGRES_DB:-dbuser}
        - POSTGRES_PASSWORD=${POSTGRES_DB:-dbpasswd}
      networks:
        - mysite-backend
      volumes:
        - static:/app/static
        - media:/app/media
        - private:/app/private
    db:
      restart: always
      image: postgis/postgis:latest
      ports:
        - "127.0.0.1:5433:5433"
      networks:
        - mysite-backend
      environment:
        - POSTGRES_DB=${POSTGRES_DB:-dbname}
        - POSTGRES_USER=${POSTGRES_DB:-dbuser}
        - POSTGRES_PASSWORD=${POSTGRES_DB:-dbpasswd}
      command: -p 5433
  networks:
    mysite-backend:
      driver: bridge
  volumes:
      static:
        driver: local
      media:
        driver: local
      private:
        driver: local

Rebuild mysite: docker build . -t mysite/backend
Then launch the Docker Compose instance to test out the configuration and PostgreSQL:
docker compose up

Open another terminal and create your super user for this container’s Wagtail using Docker Compose: docker compose exec backend python manage.py createsuperuser

Creating a location-based admin map Link to heading

In order to test our geospatial database, we are going to create our first model that includes a point field and create a point object in the admin site. To do so, we create a new application using:
python manage.py startapp maps
You will have to add this new app to the base settings base.py INSTALLED_APPS:
"maps",
Inside mysite/maps/ you should find a models.py file, this is where we declare our database schema.
nano mysite/maps/models.py

  # Replace from django.db import models with the next line
  # so that we use geodjango models
  from django.contrib.gis.db import models
  
  # Create your models here.
  
  class Location(models.Model):
      location_name = models.CharField(max_length=50)
      location = models.PointField(srid=4326)

Here is when making modifications to your database models you should not forget to migrate the changes and apply them. python manage.py migrate && python manage.py makemigrations

Wagtail is primarily on being CMS to provide a better interface and workflow than basic Django. Therefore we need to tell our site we are going to use our own models in the admin interface. Create a file wagtail_hooks.py right beside models.py inside our maps app folder:
nano wagtail_hooks.py

  from wagtail.snippets.models import register_snippet
  from wagtail.snippets.views.snippets import SnippetViewSet
  from wagtail.admin.panels import FieldPanel
  from wagtailgeowidget.panels import LeafletPanel
  
  from .models import Location
  
  class LocationTemplate(SnippetViewSet):
      model = Location
  
      panels = [
          # Here is defined which fields are displayed
         	# and what panel type is used to display them
          FieldPanel("location_name"),
          LeafletPanel("location"),
      ]
      
  register_snippet(LocationTemplate)

Go to 127.0.0.1:8001, login, then Snippets and add a location object and you either enter latitude/longitude coordinates or drag the pin to the desired location.

A word of caution, a local GDAL is still necessary in order to migrate models (python manage.py makemigrations).