The command line¶
geodeploy — upload data, build and publish portals, and operate an instance, without a browser.
The CLI talks to the same API the dashboard uses, so anything you can do in the app you can script: a nightly upload from a field database, a portal rebuilt in CI, a hundred GeoTIFFs pushed in one command. It is also the Python client the QGIS plugin is built on, so what you learn here carries over. (Not sure which of the four interfaces you want? Compare them.)
pip install geodeploy
geodeploy login https://geodeploy.example.org
geodeploy upload roads.gpkg sites.csv --wait
geodeploy portals create "Field sites 2026" --publish
No dependencies, Python 3.9+
The package installs nothing else — every request goes through the standard library. That is deliberate: it means the QGIS plugin can ship the same client without asking you to pip-install anything into QGIS.
Installing¶
Check it: geodeploy --version.
geodeploy: command not found after a successful install
pip installed the package, but the folder it puts commands in is not on your PATH. This
happens when pip says "Defaulting to user installation because normal site-packages is not
writeable" — the per-user scripts folder is not the one the Python installer added.
It always works as a module, whatever your PATH says:
To get the short name back, add the directory pip named in its warning to your PATH:
Installing into a virtual environment, or with pipx, avoids this entirely — both manage the directory for you.
Signing in¶
Create a token in the dashboard — Settings → API tokens → Create token — pick its scopes and an
expiry, and copy the gdp_… secret. It is shown once.
That stores two things: the instance URL in a config file, and the token in your operating
system's keyring if you have one, otherwise in a 0600 file next to it. The token never goes into
the config file, so you can share that file when asking for help.
| Where | What | Path |
|---|---|---|
| Config | instance URLs, profile names, the account each was logged in as | ~/.config/geodeploy/config.json (Windows: %APPDATA%\GeoDeploy\, macOS: ~/Library/Application Support/geodeploy/) |
| Credentials | tokens and sessions | OS keyring, else credentials.json alongside, mode 0600 |
Several instances¶
Every login is a profile, named after the host unless you say otherwise:
geodeploy login https://geodeploy.example.org --token gdp_aaa --name prod
geodeploy login https://staging.example.org --token gdp_bbb --name staging
geodeploy profile list # which ones exist, and which is active
geodeploy profile use prod # switch
geodeploy layers list -p staging # or override for one command
In CI, or without logging in¶
Two environment variables are enough, and nothing is written to disk:
export GEODEPLOY_URL=https://geodeploy.example.org
export GEODEPLOY_TOKEN=gdp_xxxxxxxxxxxx
geodeploy layers list
Use 127.0.0.1, not localhost, against a local instance
On Windows with WSL2, localhost resolves to IPv6 first while the published port is IPv4 only,
so every request waits for that attempt to fail. http://127.0.0.1 skips it.
Scopes, and what a token cannot do¶
A token acts as its owner, limited to its scopes and never above their live role. If a command comes
back with Token missing scope: data:write, mint one that has it.
| Scope | Lets you |
|---|---|
data:read |
list and inspect layers |
data:write |
upload, rename, restyle, share, delete layers |
portal:read |
read portal configuration |
portal:write |
create and edit portals |
portal:publish |
publish and unpublish |
users:admin |
manage members and invitations |
Administration is deliberately closed to tokens. geodeploy admin … (health, services, updates,
backups, credentials) and geodeploy token create need a password session, so that a leaked token
cannot restart your database or mint more tokens:
geodeploy login https://geodeploy.example.org --password --email [email protected]
The password is prompted for (never a flag value, so it stays out of shell history), sent once, and
never stored — what is kept is the same 7-day session the browser gets, alongside your token. In CI,
--password-stdin reads it from a pipe. geodeploy logout --session-only drops it again.
Browsing an instance¶
The one command that needs no account. Give it a URL and it prints what that instance publishes:
$ geodeploy browse https://geodeploy.example.org
https://geodeploy.example.org
2 public portal(s), 7 public layer(s)
Portals
SLUG TITLE EXPERIENCE LAYERS URL
field-sites-2026 Field sites 2026 webmap 4 https://…/portals/field-sites-2026/
soil-catalogue Soil catalogue catalog 12 https://…/portals/soil-catalogue/
Vector (PostGIS) — 3
ID NAME GEOMETRY FEATURES CRS LICENCE
a7f3c91b04e2 Roads linestring 12,480 EPSG:4326 CC-BY-4.0
…
--links prints every access URL for each layer, --kind narrows to raster, postgis or
geoparquet, and --json gives you the index as it comes.
To look inside one portal — its layers and their sources — read its published style, which is also public:
$ geodeploy browse --portal field-sites-2026
Field sites 2026
https://geodeploy.example.org/portals/field-sites-2026/
NAME KIND ID VISIBLE
Roads linestring vector-1 yes
Plots polygon vector-3 yes
DEM raster raster-2 no
Trees GeoParquet (deck.gl) deck-7 yes
The names are the ones the portal itself shows, a layer drawn by several map layers (a fill and its
outline, a polygon and its 3D pillars) is listed once, and --links adds the data URL behind each
one. VISIBLE appears only when a layer starts switched off.
A portal's URL works everywhere its slug does — paste the link straight out of the address bar and the instance comes with it, so there is nothing to log in to and nothing to retype:
geodeploy browse https://geodeploy.example.org/portals/5b5c627cfd/
geodeploy portals publish https://geodeploy.example.org/portals/5b5c627cfd/ # with a token
With a token in play the same command adds what you can see beyond the public surface. That is the two-mode behaviour a desktop plugin needs, which is why it lives here rather than only in one.
What counts as public
Portals that are published with access public, and layers explicitly shared as
public. A password, organization or owner portal is never listed, and neither is a private
layer. An instance can also switch its index off entirely — then browse says so rather than
pretending the instance is empty.
Uploading¶
One command handles every format and any number of files:
geodeploy upload roads.gpkg # one file
geodeploy upload data/*.gpkg data/*.tif --wait # a directory's worth, waiting for ingest
geodeploy upload sites.csv --x lon --y lat # CSV points
geodeploy upload plots.csv --wkt geometry # CSV with WKT geometry of any type
geodeploy upload parcels.parquet --name Parcels
--wait follows each ingest job to completion and fails the command if ingest fails — which is
what a script needs, since a queued job says nothing about whether the data was readable.
Which route a file takes¶
You do not choose; the CLI does, and --dry-run shows you what it decided before anything moves:
$ geodeploy upload big.gpkg sites.csv dem.tif --dry-run
PATH LAYER TYPE ROUTE NAME SIZE H CHUNKED REASON
big.gpkg vector large-vector big 220.4 MB yes over the 48 MB direct-upload threshold…
sites.csv vector csv-api sites 1.2 KB no
dem.tif raster raster-api dem 18.0 MB no
| Route | When | Result |
|---|---|---|
vector-api |
.gpkg .geojson .json .zip under 48 MB |
a PostGIS layer |
csv-api |
a small .csv with geometry columns |
a PostGIS layer |
large-vector |
any of those at or over 48 MB | uploaded direct to storage, converted to GeoParquet |
geoparquet |
.parquet / .geoparquet, any size |
registered in place, no conversion |
raster-api / raster-large |
.tif / .tiff, small / large |
Cloud-Optimized GeoTIFF |
Files over 48 MB never pass through the API: they go straight to object storage in 48 MB presigned parts, four at a time, each part retried on its own. This is why a multi-gigabyte upload works behind a proxy or CDN that caps request bodies at 100 MB — see Uploading data.
CSV geometry¶
Give the columns explicitly, or let the CLI read the header and offer a guess:
$ geodeploy upload sites.csv
warning: sites.csv: guessed geometry from the header (x=Longitude, y=Latitude). Pass --x/--y or --wkt to be explicit.
It never guesses silently — a wrong x/y column puts your layer in the Gulf of Guinea and nothing
reports an error. --no-guess refuses instead of guessing; --srid sets the CRS of the coordinates
(default 4326) and --delimiter overrides the sniffed separator.
Layers¶
geodeploy layers list # everything, vector and raster
geodeploy layers list --type vector --status ready
geodeploy layers list --query roads # match name, abstract or keywords
geodeploy layers show roads # one layer, in full
geodeploy layers fields roads # its attribute columns
geodeploy layers stats roads --field pop # the distribution of one attribute
geodeploy layers legend roads # the swatches and labels its style produces
geodeploy layers usage roads # which portals use it
legend is computed by the instance, using the same function the published portal and the
About page use, so what you see is what a map shows. A raster answers with its colour ramp and the
value range it is stretched over, because a continuous ramp has no swatches to list.
You can name a layer any way you have it: its id (7), its stable public id
(a7f3c91b04e2), a vector-7 reference, or its name — a unique part of the name is enough.
Ambiguity is always an error, never a guess, and there are two kinds of it:
- Two layers with the same name. The command lists the candidates with their ids and stops.
- A plain number. Vector and raster ids are separate sequences, so
1can be both a vector and a raster. Asked for a bare1when both exist, the CLI refuses and tells you to writevector-1orraster-1.
In scripts, prefer the uid (a7f3c91b04e2), which layers list shows in its own column: it is
unique across both kinds, it survives a rename, and it is what every public URL uses. An integer id
is meaningful only inside one layer kind and one database — restore an instance elsewhere and the
numbers change, while the uids do not.
geodeploy layers rename roads "Main roads"
geodeploy layers share roads --visibility public --license CC-BY-4.0 --attribution "SLU"
geodeploy layers links roads # the URL for each tool, labelled
geodeploy layers delete roads --yes
Downloading a layer¶
geodeploy layers download roads # PostGIS layer → a built GeoPackage
geodeploy layers download parcels # GeoParquet layer → its own files, uncapped
geodeploy layers download roads --format csv
geodeploy layers download roads --bbox 11.8,57.6,12.1,57.8 # just that area
geodeploy layers download dem # the Cloud-Optimized GeoTIFF itself
Two different mechanisms sit behind that one command, and it matters which you get:
| What you ask for | How it arrives |
|---|---|
cog, pmtiles, a whole GeoParquet layer |
the stored files, streamed as they are — no server work, no row cap, lossless |
gpkg, csv, geojson, or anything clipped |
built by the instance as a job, and arrives as a zip |
--crs native keeps the layer's own CRS where the format can carry it (GeoPackage, CSV,
GeoParquet). GeoJSON is always EPSG:4326, as the format requires.
For a GeoParquet layer, download writes a folder — the dataset manifest plus every partition
file, exactly as the instance stores them. Read it with one line:
A built export is capped, and says so
The instance builds an export in worker memory, so it stops at 1,000,000 features for a
whole layer (50,000 for a bbox clip). If your layer reaches that, the command prints
INCOMPLETE, names the file, exits non-zero, and points you at an uncapped route —
and MANIFEST.txt inside the zip repeats it with the row count of every file.
A truncated export is not a sample: the rows are whichever the scan reached first. For layers
that big use geodeploy layers download on a GeoParquet layer (above), or OGC API - Features,
which is paged and unlimited:
This works without a token for a layer that is public — including by name, which the CLI resolves through the instance's public index. It is how someone with no account takes a copy of data you have shared.
--visibility public is the opt-in that puts a layer in the STAC catalog and OGC API -
Features. Nothing is public by default. layers links then prints the URL to hand
to each tool — WMTS for QGIS, TileJSON for MapLibre, and so on.
Fixing a stuck layer¶
geodeploy layers reprocess "field sites" --wait # restart processing, no re-upload
geodeploy layers tile roads --wait # (re)build the PMTiles archive
geodeploy layers prepare parcels # re-run GeoParquet preparation
Styling¶
The same styling flags work in three places: on a layer's default style, when adding a layer to a portal, and when restyling a layer on a portal. Only the flags you pass change; the rest of the style is left alone.
geodeploy layers style roads --color '#e11d48' --line-width 2 --line-type dashed
geodeploy layers style sites --marker star --radius 6 --outline-color none
geodeploy layers style dem --colormap terrain --rescale 0,2400
geodeploy layers style dem --algorithm contours --increment 25 --rescale 0,2400
--rescale is not optional for contours: the algorithm colours the terrain across that range and
draws the lines on top of it, so without a range the whole raster comes out one flat colour.
geodeploy layers stats dem suggests one.
| Flag | For |
|---|---|
--color |
polygon fill, line colour, or point colour |
--fill-opacity, --opacity |
fill opacity; whole-layer opacity |
--outline-color, --outline-width |
outline colour (none for no outline) and its width. On points a fraction of the radius — a wide one is how a ring is drawn; on polygons a width in px (default 1) |
--line-width, --line-type |
width in px; solid dashed dotted |
--radius, --marker |
point size; circle square triangle diamond star cross |
--colormap, --reverse-colormap, --rescale, --bidx |
raster: colour ramp, its direction, stretch, band selection |
--algorithm, --zfactor |
raster: hillshade or contours; hillshade's vertical exaggeration |
--increment, --thickness, --minz, --maxz |
contours: interval in the raster's own units, line width in px, and the range the relief behind the lines is coloured over (defaults to --rescale) |
Colour by a field¶
# graduated: numeric classes computed from the data
geodeploy portals style 3 parcels --color-field population --classify quantile --classes 5 --ramp magma
# categorized: one colour per distinct value
geodeploy portals style 3 landcover --color-field type --classify
# or state the classes yourself
geodeploy portals style 3 parcels --color-field pop --class-breaks '*-100:#fee,100-500:#f88,500-*:#f00'
geodeploy portals style 3 landcover --color-field type --categories 'forest:#2c7,water:#39f'
--classify asks the instance to compute the breaks (quantile, equal, or jenks natural
breaks) with the same code the portal editor and the published map use, so a CLI-styled layer lands
in exactly the classes the editor would show. Ramps: viridis magma blues reds greens
oranges rdbu brbg spectral.
--reverse-ramp runs the ramp the other way — the light end for the low values, which is what
a dark basemap usually wants, and how a diverging ramp is flipped to match a convention your
readers already have:
The direction is stored with the style, so re-classifying later (a different method, more classes) keeps it.
Size and 3D work the same way:
geodeploy portals style 3 towns --size-field population --size-stops '0:3,1000000:20'
geodeploy portals style 3 buildings --extrude --extrude-field height --extrude-scale 1.5
geodeploy portals style 3 stations --extrude --extrude-field depth --extrude-radius 250
Anything the flags do not cover goes through --style-json '{"…":…}' or --style-json @style.json.
Portals¶
geodeploy portals list
geodeploy portals create "Field sites 2026"
geodeploy portals create "Catalogue" --experience catalog --access organization
--experience picks the archetype — webmap (default), storymap or catalog. --access sets
who may view the published portal: public, password (with --password), organization (any
signed-in member) or owner.
Arranging layers¶
geodeploy portals add-layer 3 roads --color '#e11d48'
geodeploy portals add-layer 3 dem --colormap terrain --bottom
geodeploy portals layers 3 # what is on it, top of the list first
geodeploy portals move-layer 3 roads top
geodeploy portals remove-layer 3 dem
Index 0 is the top of the layer list and draws on top, so add-layer puts a layer at the top
unless you pass --bottom. With no styling flags, a layer arrives with the default style it has in
My Data.
Publishing¶
geodeploy portals set-description 3 @about.md # the About page, in Markdown
geodeploy portals asset 3 logo.svg
geodeploy portals publish 3
geodeploy portals url 3
Editing changes a draft
The live portal keeps serving its previous version until you publish. Every command that
changes something says so, and most accept --publish to do both in one step.
Editing the whole configuration¶
geodeploy portals export 3 portal3.json # everything editable, as JSON
# …edit layers, symbology, folders, story sections, theme…
geodeploy portals import 3 portal3.json
geodeploy portals publish 3
This round trip is how the plugins work, and it is the one to reach for when you want a portal under version control. The file is written as UTF-8 without a BOM, and read back the same way, so it survives PowerShell.
Downloading an area¶
Everything else¶
geodeploy sources add "Orthophoto" https://wms.example.org/wms --type wms --layer-name ortho_2025
geodeploy sources add "OSM" 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' --type xyz
geodeploy sources list
A WFS is probed when registered, so a wrong typeName fails immediately rather than as an
empty layer on a published map.
geodeploy import db-list # spatial tables not yet registered
geodeploy import db-add public.roads
geodeploy import storage-list --kind geoparquet
geodeploy import storage-add vectors/parcels.parquet --wait
geodeploy import csv uploads/sites.csv --x lon --y lat
Nothing is copied: tables and objects are registered where they already are.
geodeploy catalog collections # OGC API - Features, one per public vector layer
geodeploy catalog items vector-a7f3c91b04e2 --bbox 11,55,24,69 --limit 5
geodeploy catalog stac
geodeploy catalog search --bbox 11,55,24,69
geodeploy catalog templates
These need no credentials — which makes them the honest check on what you have actually published. If a layer is not listed here, nobody outside your organisation can see it either.
geodeploy admin health
geodeploy admin logs celery -n 200
geodeploy admin service celery restart
geodeploy admin storage
geodeploy admin updates --refresh
geodeploy admin update v1.3.0 --watch
geodeploy admin backups --run
geodeploy admin audit --action portal --since 2026-08-01T00:00:00Z
geodeploy admin public-index --off # stop listing this instance publicly
All of these need a password session (see above). geodeploy users … does not — it is
scope-gated, so a token with users:admin can manage members.
Scripting it¶
Every command takes --json, and in that mode stdout is exactly one JSON document — progress,
warnings and hints all go to stderr, so a pipe stays clean.
geodeploy layers list --json | jq -r '.[] | select(.status=="ready") | .name'
# publish every draft portal
for id in $(geodeploy portals list --draft --json | jq -r '.[].id'); do
geodeploy portals publish "$id"
done
Errors are JSON too — {"ok": false, "error": "…"} — so a script can read the failure instead of
scraping it.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
success |
1 |
the operation failed (the instance said no, or a job failed) |
2 |
the command line was wrong |
3 |
authentication: no credential, expired token, missing scope, role too low |
4 |
the instance could not be reached |
5 |
the instance returned a server error |
Separating these is what lets a nightly job alert on "the token expired" without alerting on "the instance was restarting".
A worked example¶
#!/usr/bin/env bash
set -euo pipefail
export GEODEPLOY_URL=https://geodeploy.example.org
export GEODEPLOY_TOKEN="$CI_GEODEPLOY_TOKEN"
geodeploy upload exports/*.gpkg --wait --json > uploaded.json
portal=$(geodeploy portals list --query "Monitoring" --json | jq -r '.[0].id')
for name in $(jq -r '.uploaded[].name' uploaded.json); do
geodeploy portals add-layer "$portal" "$name" --replace \
--color-field status --classify quantile --classes 5
done
geodeploy portals publish "$portal"
From Python¶
The CLI is a thin shell over a client you can import — the same one the QGIS plugin uses:
from geodeploy import Client
gd = Client("https://geodeploy.example.org", token="gdp_…")
result = gd.uploads.upload("roads.gpkg", wait=True)
portal = gd.portals.create("Roads")
gd.portals.add_layer(portal["id"], result.layer_id, "vector", {"color": "#e11d48"})
gd.portals.publish(portal["id"])
Failures raise typed exceptions — AuthError, PermissionError_, NotFoundError,
ValidationError, ServerError, TransportError, JobFailed — so calling code can tell "your
token is wrong" from "that layer is gone" from "the network blipped" without reading status codes.
Uploads report progress and can be cancelled:
gd.uploads.upload("huge.parquet",
on_progress=lambda done, total: print(done, "/", total),
cancel=lambda: stop_requested)
And the transport is swappable, which is how a desktop plugin inherits the host application's proxy and certificate settings:
Reading a style¶
A plugin that renders a GeoDeploy layer in another tool has to understand the style, not just carry
it. parse_style() reads one without you reaching into the dict:
from geodeploy import parse_style
layer = gd.layers.resolve("cities")
style = parse_style(layer["default_style"]) # accepts the layer record or the inner style
style.mode # 'single' | 'graduated' | 'categorized'
style.field # the attribute the colours are driven by, or None
style.classes # [{min, max, color}] — min/max None at the ends means an OPEN bucket
style.categories # [{value, color}], with style.other_color for everything else
style.size # {'field': 'pop', 'stops': [[0, 4], [1000000, 24]]} or None
style.extrusion # the 3D block, or None when the author switched it off
style.rescale # a raster stretch, as numbers
style.raw # anything not modelled above
It never rejects and never invents a default it cannot know, and style.to_dict() returns the
original, so build_style → parse_style → build_style round-trips unchanged.
For the legend, prefer asking the instance — that answer is the one the published portal drew:
gd.vector.legend("cities") # {'entries': [{'color': …, 'label': '10 – 90'}], …}
gd.raster.legend("dem") # a ramp: colormap, rescale, algorithm, zfactor, contours, bidx
style.legend() computes the same labels locally, for a caller holding an unsaved style that has
no URL to ask about yet.
Troubleshooting¶
No instance configured. — Run geodeploy login <url>, or set GEODEPLOY_URL. geodeploy
profile show prints which instance and credential a command would use, and where each came from.
Token missing scope: … — The token is valid but was minted without that scope. Create a new
one in Settings → API tokens.
… administration is session-only by design — You are using an API token on a route that only
accepts a browser-style session. Run geodeploy login --password.
An upload stalls or fails with a network error — Almost always a proxy body limit. Files over 48 MB already avoid it; if a smaller one fails, force the direct route by uploading it as GeoParquet, or check what sits in front of the instance.
A layer sits at processing forever — The worker was probably recreated mid-conversion.
geodeploy layers reprocess <layer> restarts it without re-uploading.
Self-signed certificate — --insecure skips TLS verification. Only for a lab instance you
control.
Last updated¶
2026-08-12 (the packaged CLI ships in v1.3, replacing examples/geodeploy_cli.py)