diff --git a/env.production b/env.production
index 10b3f52..8739e37 100644
--- a/env.production
+++ b/env.production
@@ -8,3 +8,4 @@ NEXTCLOUD_HOSTNAME=cloud.hackerspace.zone
GRAFANA_HOSTNAME=dashboard.hackerspace.zone
GITEA_HOSTNAME=git.hackerspace.zone
MATRIX_HOSTNAME=matrix.hackerspace.zone
+MOBILIZON_HOSTNAME=events.hackerspace.zone
diff --git a/html/index.html b/html/index.html
index 7f71a17..2b08ecd 100644
--- a/html/index.html
+++ b/html/index.html
@@ -6,6 +6,7 @@ An easy to install set of self-hosted, single-sign-on, open-source services.
matrix: realtime chat
hedgedoc: collaborative markdown editing
mastodon: federated social media
+mobilizon: event planning and RSVP
nextcloud: self hosted documents and calendaring
grafana: dashboards and statistic collection
gitea: git repository hosting
diff --git a/mobilizon/config.exs b/mobilizon/config.exs
new file mode 100644
index 0000000..d97be56
--- /dev/null
+++ b/mobilizon/config.exs
@@ -0,0 +1,108 @@
+# Mobilizon instance configuration
+
+import Config
+
+listen_ip = System.get_env("MOBILIZON_INSTANCE_LISTEN_IP", "0.0.0.0")
+
+listen_ip =
+ case listen_ip |> to_charlist() |> :inet.parse_address() do
+ {:ok, listen_ip} -> listen_ip
+ _ -> raise "MOBILIZON_INSTANCE_LISTEN_IP does not match the expected IP format."
+ end
+
+config :mobilizon, Mobilizon.Web.Endpoint,
+ server: true,
+ url: [host: System.get_env("MOBILIZON_INSTANCE_HOST", "mobilizon.lan")],
+ http: [
+ port: String.to_integer(System.get_env("MOBILIZON_INSTANCE_PORT", "4000")),
+ ip: listen_ip
+ ],
+ secret_key_base: System.get_env("MOBILIZON_INSTANCE_SECRET_KEY_BASE", "changethis")
+
+config :mobilizon, Mobilizon.Web.Auth.Guardian,
+ secret_key: System.get_env("MOBILIZON_INSTANCE_SECRET_KEY", "changethis")
+
+config :mobilizon, :instance,
+ name: System.get_env("MOBILIZON_INSTANCE_NAME", "Mobilizon"),
+ description: "Change this to a proper description of your instance",
+ hostname: System.get_env("MOBILIZON_INSTANCE_HOST", "mobilizon.lan"),
+ registrations_open: System.get_env("MOBILIZON_INSTANCE_REGISTRATIONS_OPEN", "false") == "true",
+ demo: false,
+ allow_relay: true,
+ federating: true,
+ email_from: System.get_env("MOBILIZON_INSTANCE_EMAIL", "noreply@mobilizon.lan"),
+ email_reply_to: System.get_env("MOBILIZON_REPLY_EMAIL", "noreply@mobilizon.lan")
+
+config :mobilizon, Mobilizon.Storage.Repo,
+ adapter: Ecto.Adapters.Postgres,
+ username: System.get_env("MOBILIZON_DATABASE_USERNAME", "username"),
+ password: System.get_env("MOBILIZON_DATABASE_PASSWORD", "password"),
+ database: System.get_env("MOBILIZON_DATABASE_DBNAME", "mobilizon"),
+ hostname: System.get_env("MOBILIZON_DATABASE_HOST", "postgres"),
+ port: 5432,
+ pool_size: 10
+
+config :mobilizon, Mobilizon.Web.Email.Mailer,
+ adapter: Swoosh.Adapters.SMTP,
+ relay: System.get_env("MOBILIZON_SMTP_SERVER", "localhost"),
+ port: System.get_env("MOBILIZON_SMTP_PORT", "25"),
+ username: System.get_env("MOBILIZON_SMTP_USERNAME", nil),
+ password: System.get_env("MOBILIZON_SMTP_PASSWORD", nil),
+ tls: :if_available,
+ allowed_tls_versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"],
+ ssl: System.get_env("MOBILIZON_SMTP_SSL", "false"),
+ retries: 1,
+ no_mx_lookups: false,
+ auth: :if_available
+
+config :geolix,
+ databases: [
+ %{
+ id: :city,
+ adapter: Geolix.Adapter.MMDB2,
+ source: "/var/lib/mobilizon/geo_db/GeoLite2-City.mmdb"
+ }
+ ]
+
+config :mobilizon, Mobilizon.Web.Upload.Uploader.Local,
+ uploads: System.get_env("MOBILIZON_UPLOADS", "/var/lib/mobilizon/uploads")
+
+config :mobilizon, :exports,
+ path: System.get_env("MOBILIZON_UPLOADS_EXPORTS", "/var/lib/mobilizon/uploads/exports"),
+ formats: [
+ Mobilizon.Service.Export.Participants.CSV,
+ Mobilizon.Service.Export.Participants.PDF,
+ Mobilizon.Service.Export.Participants.ODS
+ ]
+
+config :tz_world,
+ data_dir: System.get_env("MOBILIZON_TIMEZONES_DIR", "/var/lib/mobilizon/timezones")
+
+
+#
+# keycloak config for hackerspace.zone self hosted single-sign-on
+#
+keycloak_hostname = System.get_env("KEYCLOAK_HOSTNAME", "keycloak.example.com")
+keycloak_realm = System.get_env("REALM", "example")
+keycloak_secret = System.get_env("MOBILIZON_CLIENT_SECRET", "abcdef1234")
+keycloak_url = "https://#{keycloak_hostname}/realms/#{keycloak_realm}"
+
+config :ueberauth,
+ Ueberauth,
+ providers: [
+ keycloak: {Ueberauth.Strategy.Keycloak, [default_scope: "openid"]}
+ ]
+
+config :mobilizon, :auth,
+ oauth_consumer_strategies: [
+ {:keycloak, "#{keycloak_hostname}"}
+ ]
+
+config :ueberauth, Ueberauth.Strategy.Keycloak.OAuth,
+ client_id: "mobilizon",
+ client_secret: keycloak_secret,
+ site: keycloak_url,
+ authorize_url: "#{keycloak_url}/protocol/openid-connect/auth",
+ token_url: "#{keycloak_url}/protocol/openid-connect/token",
+ userinfo_url: "#{keycloak_url}/protocol/openid-connect/userinfo",
+ token_method: :post
diff --git a/mobilizon/docker-compose.yml b/mobilizon/docker-compose.yml
new file mode 100644
index 0000000..4f359ff
--- /dev/null
+++ b/mobilizon/docker-compose.yml
@@ -0,0 +1,26 @@
+version: "3"
+
+services:
+ mobilizon:
+ image: framasoft/mobilizon
+ restart: always
+ env_file:
+ - ../env.production
+ - ./env.production
+ - ../data/mobilizon/env.secrets
+ volumes:
+ - ../data/mobilizon/uploads:/var/lib/mobilizon/uploads
+ - ./config.exs:/etc/mobilizon/config.exs:ro
+ # - ${PWD}/GeoLite2-City.mmdb:/var/lib/mobilizon/geo_db/GeoLite2-City.mmdb
+ ports:
+ - "7000:7000"
+
+ db:
+ image: postgis/postgis:13-3.1
+ restart: always
+ volumes:
+ - ../data/mobilizon/db:/var/lib/postgresql/data
+ environment:
+ - POSTGRES_USER=mobilizon
+ - POSTGRES_PASSWORD=mobilizon
+ - POSTGRES_DB=mobilizon
diff --git a/mobilizon/env.production b/mobilizon/env.production
new file mode 100644
index 0000000..4f67db1
--- /dev/null
+++ b/mobilizon/env.production
@@ -0,0 +1,24 @@
+# Database settings
+POSTGRES_USER=mobilizon
+POSTGRES_PASSWORD=changethis
+POSTGRES_DB=mobilizon
+MOBILIZON_DATABASE_USERNAME=mobilizon
+MOBILIZON_DATABASE_PASSWORD=mobilizon
+MOBILIZON_DATABASE_DBNAME=mobilizon
+MOBILIZON_DATABASE_HOST=db
+
+
+# Instance configuration
+MOBILIZON_INSTANCE_REGISTRATIONS_OPEN=false
+MOBILIZON_INSTANCE_PORT=7000
+
+MOBILIZON_INSTANCE_EMAIL=noreply@mobilizon.lan
+MOBILIZON_REPLY_EMAIL=contact@mobilizon.lan
+
+# Email settings
+MOBILIZON_SMTP_SERVER=localhost
+MOBILIZON_SMTP_PORT=25
+MOBILIZON_SMTP_HOSTNAME=localhost
+MOBILIZON_SMTP_USERNAME=noreply@mobilizon.lan
+MOBILIZON_SMTP_PASSWORD=password
+MOBILIZON_SMTP_SSL=false
diff --git a/mobilizon/setup b/mobilizon/setup
new file mode 100755
index 0000000..3a35780
--- /dev/null
+++ b/mobilizon/setup
@@ -0,0 +1,50 @@
+#!/bin/bash
+die() { echo >&2 "mobilizon: $@" ; exit 1 ; }
+
+DIRNAME="$(dirname $0)"
+cd "$DIRNAME"
+source ../env.production || die "no top level env?"
+source env.production || die "no local env?"
+
+DATA="../data/mobilizon"
+SECRETS="$DATA/env.secrets"
+
+if [ -r "$SECRETS" ]; then
+ docker-compose up -d || die "unable to start"
+ exit 0
+fi
+
+docker-compose down 2>/dev/null
+
+CLIENT_SECRET="$(openssl rand -hex 20)"
+
+mkdir -p "$DATA/uploads"
+chmod 777 "$DATA/uploads"
+
+mkdir -p "$(dirname "$SECRETS")"
+cat < "$SECRETS"
+# DO NOT CHECK IN
+MOBILIZON_INSTANCE_NAME=${DOMAIN_NAME}
+MOBILIZON_INSTANCE_HOST=${MOBILIZON_HOSTNAME}
+MOBILIZON_INSTANCE_SECRET_KEY_BASE=$(openssl rand -hex 20)
+MOBILIZON_INSTANCE_SECRET_KEY=$(openssl rand -hex 20)
+MOBILIZON_CLIENT_SECRET=${CLIENT_SECRET}
+EOF
+
+../keycloak/client-delete mobilizon
+
+../keycloak/client-create < /dev/null || die "$host: DNS entry not present?"