Twingate-Labs/Twingate-CLI
Twingate-Labs/Twingate-CLI has 26 GitHub stars and 16 forks. GitHot measured 0 stars in 24 hours. Last checked 2026-11-14 10:15 UTC. Last push 2026-11-06.
Measured star growth
- in 24h
- 0
- in 7 days
- Collecting history
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
About
Python CLI for the Twingate Admin GraphQL API. Manage remote networks, resources, connectors, groups, users, service accounts, gateways, DNS security, and more from the command line. Supports CSV/JSON/DataFrame output, concurrent pagination, and bulk operations like group migration.
Developer ToolsREADME.md
OriginalTwingate Admin CLI
A command-line interface for the Twingate Admin API. Manage your resources, devices, users, groups, connectors, networks, service accounts, and more — directly from your terminal.
Table of Contents
- Requirements
- Installation
- Quick Start
- Global Options
- Output Formats
- Multiple Sessions
- Shell completion
- Commands
- Rate Limiting
- Development
Requirements
- Python 3.9 or later
- A Twingate account with an Admin API token
Installation
# 1. Clone the repository
git clone https://github.com/Twingate-Labs/Twingate-CLI.git
cd Twingate-CLI
# 2. (Recommended) Create a virtual environment
python3 -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows
# 3. Install
pip install .
The tgcli command is now available in your shell.
To install a tagged release instead of the latest main, use pip install "git+https://github.com/Twingate-Labs/[email protected]". What changed in each release, and anything that breaks existing scripts, is in CHANGELOG.md. tgcli -v shows the version you have.
Linux headless / CI environments
tgclistores credentials in your OS keychain via thekeyringlibrary. On headless Linux you may need a fallback backend:pip install keyrings.alt export PYTHON_KEYRING_BACKEND=keyrings.alt.file.PlaintextKeyring
Windows
tgcli is pure Python and should run on Windows, but it is developed and tested on macOS and has not yet been tested on Windows. If something does not work, please report it.
git clone https://github.com/Twingate-Labs/Twingate-CLI.git
cd Twingate-CLI
py -3 -m venv .venv
.venv\Scripts\Activate.ps1 # PowerShell (cmd.exe: .venv\Scripts\activate.bat)
pip install .
- PowerShell blocks
Activate.ps1? If it says "running scripts is disabled on this system", allow scripts for the current window only withSet-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned, then activate again. - Credentials are kept in Windows Credential Manager (look for entries mentioning
tgcli), as the Keychain is used on macOS. - The examples in this README are written for bash. Where a command continues on the next line with
\, use a backtick`at the end of the line in PowerShell,^in cmd.exe, or put the command on one line. Commands that use$(...),head,greporcronneed PowerShell equivalents (Task Scheduler instead of cron). - Output is always UTF-8. When output is saved to a file or piped (
> file.csv),tgcliwrites UTF-8 whatever the Windows code page is, so a name in Greek, Cyrillic, Chinese, Japanese or emoji no longer makes CSV or table output fail with aUnicodeEncodeError. Reports (group migrate,import) andtgcli backupfiles are UTF-8 too. - Windows PowerShell 5.1 re-encodes what it receives. It reads a program's output using the console code page and saves
>redirection as UTF-16, so non-English characters can come out wrong. For clean UTF-8 files, use PowerShell 7 (pwsh), or in 5.1 run[Console]::OutputEncoding = [System.Text.Encoding]::UTF8first and use| Out-File -Encoding utf8 file.csv. (This has not been tried on a Windows machine yet.) - Files you give
tgclican be UTF-8 (with or without a byte order mark) or UTF-16, which is what PowerShell 5.1 andOut-File -Encoding utf8produce. This coversdevice list --fileofids,ca create --fileand the files read byimport. scripts/disable_offline_resources.shis a bash script. Run it from Git Bash or WSL.
Quick Start
# Log in — stores your credentials in the OS keychain under a session name you choose (-s)
tgcli auth login -a YOUR_API_KEY -t acme -s acme
# Verify it worked
tgcli -v # tgcli version 3.2.1
tgcli -s acme resource list # list all resources
Your Twingate tenant name is the subdomain of your Twingate URL.
e.g. for acme.twingate.com the tenant name is acme.
The API key is created in the Admin Console under Settings > API. It is a long string (about 130 characters of letters, digits, - and _) with no fixed prefix, and it is only shown once, so copy it when it is generated. Use a read-only key unless you want the CLI to change things.
Every command that talks to Twingate needs
-s <session>. There is no default session. Put-sbefore the command, as above. If you leave-soffauth login,tgclimakes up a random session name, and you then have to pass that name with-severy time (tgcli auth listshows it). Naming your session when you log in avoids that.To keep the examples in this README short, most of them leave
-s <session>out. Add it:tgcli resource listis reallytgcli -s acme resource list.
Shell completion
tgcli --install-completion # then restart your shell
Press TAB to complete commands, subcommands and flags. Completion is provided for zsh, bash (4.4 or newer; the bash 3.2 that ships with macOS is not supported), fish and PowerShell, and has been tested with zsh. Two options also complete their values:
-s/--sessioncompletes your saved session names.group migrate --groupcompletes the names of the groups it can migrate (active Synced Groups). A name shared by several groups is offered as an ID instead, becausemigratewill not accept an ambiguous name. This asks the Twingate API, so type-s <session>first, as intgcli -s prod group migrate --group <TAB>. Without a session it offers nothing.
Notes:
- The group names are cached for 2 minutes in
~/.cache/tgcli(%LOCALAPPDATA%\tgclion Windows), in files only you can read, so repeated TAB presses do not call the API each time. - Completion never prints errors. If the API cannot be reached or is rate-limiting, it offers the last list it saved, or nothing.
- If
--install-completionsays "Shell not supported" (it detects your shell from the parent process), generate the script yourself and load it from your shell's startup file. For zsh, addeval "$(_TGCLI_COMPLETE=source_zsh tgcli)"(usesource_bashfor bash). The same variable withsource_fishorsource_powershellprints the script for those shells.
Global Options
These flags go before the command name and apply to every call:
| Flag | Short | Description | Default |
|---|---|---|---|
--session |
-s |
Session to use. Required for every command that contacts Twingate; there is no default session | none |
--format |
-f |
Output format: JSON, CSV, DF |
JSON |
--log |
-l |
Log level: DEBUG, INFO, WARNING, ERROR |
ERROR |
--version |
-v |
Print version and exit |
tgcli -s staging -f CSV device list
Output Formats
Every command supports three output formats:
tgcli resource list # JSON (default)
tgcli -f CSV resource list # CSV
tgcli -f DF resource list # Pandas DataFrame (tabular, great for terminals)
Delete commands ask first. account, connector, group, gateway, network, resource, user, ca and dns-profile delete, and key delete / key revoke, ask "Delete Group ? This cannot be undone. [y/N]" before they act; only y or yes goes ahead, and anything else (including just Enter) deletes nothing and exits 1. The question goes to standard error, so standard output is still the result alone. --yes (-y) skips the question. With no terminal to ask on (a script, cron job or pipe) a delete command refuses and exits 1 unless you pass --yes, so a script that calls one has to say so: tgcli -s prod group delete -i "<id>" --yes. ca delete, dns-profile delete and gateway delete report an ID that does not exist before asking, instead of answering ok for a delete that did nothing. Removing something from a list (group removeUsers, resource access remove, device snumber remove) is not asked about: it is easy to put back.
What a command prints, and its exit code. Standard output is the result alone, so any format can be piped or parsed: progress and advice (such as the reminder after resource create, or the steps of key rotate) go to standard error. A command exits 0 when it did what you asked. If the API refuses a change (it answers ok: false, for example a missing ID or a rule such as "Bypass Resource cannot have a Security Policy"), the refusal is still printed in full on standard output, the reason is repeated on standard error as Error: ..., and the exit code is 1, so a script can tell a refused change from a made one. Commands that change several things (device updateTrust -l, group migrate, import) attempt every one, then exit 1 if any failed.
Multiple Sessions
You can manage several Twingate tenants at once by naming your sessions:
# Log in to two tenants
tgcli auth login -a YOUR_API_KEY_1 -t acme -s prod
tgcli auth login -a YOUR_API_KEY_2 -t acme-staging -s staging
# Use them with -s (required, even if you only have one session)
tgcli -s prod resource list
tgcli -s staging resource list
# See all saved sessions
tgcli auth list
# Log out of a specific session
tgcli auth logout -s staging
Commands
auth
Manage authentication sessions.
# Log in (saves credentials to OS keychain). Always name the session with -s:
tgcli auth login -a YOUR_API_KEY -t acme -s my-session
# Without -s a random session name is generated, and you must then pass that name with -s on every command.
# List all saved sessions
tgcli auth list
# Log out (-s is required: there is no default session)
tgcli auth logout -s my-session
device
Manage Twingate devices and the serial-number allowlist.
# List all devices
tgcli device list
# Filter server-side (filters combine with AND)
tgcli device list --untrusted
tgcli device list --trusted --state ACTIVE # --state: ACTIVE, ARCHIVED, BLOCKED (repeatable)
tgcli device list --serial-number "C02XYZ123"
tgcli device list --user-id "VXNlcjoxMjM=" # repeatable
# IDs only, and diff against a saved list of IDs
tgcli -f CSV device list --idsonly
tgcli device list --fileofids known_ids.txt # prints IDs "new" to the tenant and "missing" from it
# Show a specific device
tgcli device show -i "RGV2aWNlOjE5MzI2OQ=="
# Update trust status
tgcli device updateTrust -i "RGV2aWNlOjE5MzI2OQ==" -t True
tgcli device updateTrust -i "RGV2aWNlOjE5MzI2OQ==" -t False
# Update trust for multiple devices at once
tgcli device updateTrust -l "RGV2aWNlOjE5MzI2OQ==,RGV2aWNlOjE5MzI3MA==" -t True
# Block / unblock / archive / unarchive a device
tgcli device block -i "RGV2aWNlOjE5MzI2OQ=="
tgcli device unblock -i "RGV2aWNlOjE5MzI2OQ=="
tgcli device archive -i "RGV2aWNlOjE5MzI2OQ=="
tgcli device unarchive -i "RGV2aWNlOjE5MzI2OQ=="
# --- Serial number allowlist ---
tgcli device snumber list
tgcli device snumber add -n "SN001,SN002,SN003"
tgcli device snumber remove -n "SN001"
# --- Get Posture Checks ---
tgcli device posture -i "RGV2aWNlOjE5MzI2OQ=="
# --- Check Internet Security Client Configuration (e.g. Machine Key deployment) ---
tgcli device show -i "RGV2aWNlOjE5MzI2OQ==" # see the internetSecurityConfiguration field
tgcli -f CSV device list # export all devices with that column included
device list / device show include an internetSecurityConfiguration field reporting each device's Internet Security Client Configuration state — used to see which devices have a Machine Key deployed so Internet Security features (e.g. DNS filtering) keep running while the user is signed out.
resource
Manage Twingate resources and their access controls.
# List / show. Both have a "type" column: NETWORK, SSH, WEB_APP or KUBERNETES, and after the original columns
# the access policy (mode, duration), the approval mode and, for Gateway-backed types, the gateway,
# upstream/downstream ports (plus TLS modes for web apps) and the cluster ref (Kubernetes). Columns that don't
# apply to a resource's type are blank. `list --detail` and `show` also have the approver groups (IDs, plus names in
# the same order), who has access (`accessNames`) and when each grant expires (`accessExpiresAt`, `None` for access
# that never does; both in the same order as `access.edges`). `list` leaves the approver groups out by default
# because they are what makes `--detail` slower on large tenants (about 18% at 65 resources). `show` also has
# `access.edges`, as `list` does.
tgcli resource list
tgcli resource list --detail # (-d) adds the approver groups and the names and expiry of each access grant
tgcli resource show -i "UmVzb3VyY2U6MQ=="
# List only disabled (or only enabled) resources
tgcli resource list -a false
tgcli resource list -a true
# Create a resource (-r Remote Network is required, -p Security Policy is optional; each an ID or an exact name)
tgcli resource create \
-a 10.0.0.0/24 \
-n "Internal Network" \
-r "UmVtb3RlTmV0d29yazox" \
-p "U2VjdXJpdHlQb2xpY3k6MQ=="
# The same by name, and give two groups access in the same command (-g takes IDs or names)
tgcli resource create \
-a app.internal.example.com \
-n "Internal App" \
-r "Cloud Applications" \
-p "Default Policy" \
-g "Engineering,Ops"
# Create with TCP port restriction and a security policy
tgcli resource create \
-a app.internal.example.com \
-n "Internal App" \
-r "UmVtb3RlTmV0d29yazox" \
-t RESTRICTED \
-c "[[443,443],[8080,8080]]" \
-p "U2VjdXJpdHlQb2xpY3k6MQ=="
# Create a bypass resource (traffic connects directly, skipping Twingate).
# A bypass resource cannot have a Security Policy, so leave out -p (the CLI refuses the combination)
tgcli resource create \
-a app.internal.example.com \
-n "Bypass App" \
-r "UmVtb3RlTmV0d29yazox" \
-m BYPASS_TWINGATE
# Delete a resource
tgcli resource delete -i "UmVzb3VyY2U6MQ=="
# Assign a resource to a different remote network
tgcli resource assignNetwork -i "UmVzb3VyY2U6MQ==" -n "UmVtb3RlTmV0d29yazoy"
# Update resource visibility
tgcli resource visibility -i "UmVzb3VyY2U6MQ==" -v True
tgcli resource visibility -i "UmVzb3VyY2U6MQ==" -v False
# Update address or alias
tgcli resource address -i "UmVzb3VyY2U6MQ==" -a 10.0.1.0/24
tgcli resource alias -i "UmVzb3VyY2U6MQ==" -a myapp.internal
# Update security policy
tgcli resource policy -i "UmVzb3VyY2U6MQ==" -p "U2VjdXJpdHlQb2xpY3k6MQ=="
# Update routing mode on an existing resource (the API refuses BYPASS_TWINGATE for a resource that has a Security Policy)
tgcli resource routing -i "UmVzb3VyY2U6MQ==" -m BYPASS_TWINGATE
tgcli resource routing -i "UmVzb3VyY2U6MQ==" -m THROUGH_TWINGATE
# Auto-lock access after N days of non-use (-a is days, 1 to 365; turn it off with -a -1)
tgcli resource autolock -i "UmVzb3VyY2U6MQ==" -a 7
tgcli resource autolock -i "UmVzb3VyY2U6MQ==" -a 30 -r True # auto-approve
# Auto-approve mode
tgcli resource autoapprove -i "UmVzb3VyY2U6MQ==" -r True
# Enable / disable a resource
tgcli resource enable -i "UmVzb3VyY2U6MQ=="
tgcli resource disable -i "UmVzb3VyY2U6MQ=="
# --- Access control: `resource access add | set | remove` ---
# Add group access (non-destructive). Groups, policies and service accounts can be IDs or exact names
tgcli resource access add -i "UmVzb3VyY2U6MQ==" \
-g "R3JvdXA6Mw==" \
-p "U2VjdXJpdHlQb2xpY3k6MQ=="
tgcli resource access add -i "UmVzb3VyY2U6MQ==" -g "Engineering" -p "Strict Policy"
# A different policy per group: give one -p per group, in the same order
tgcli resource access add -i "UmVzb3VyY2U6MQ==" -g "Engineering,Ops" -p "Strict Policy,Default Policy"
# Add service account access (a service account cannot have an expiry or auto-lock; see below)
tgcli resource access add -i "UmVzb3VyY2U6MQ==" -s "U2VydmljZUFjY291bnQ6MQ=="
# Give a group access that expires
tgcli resource access add -i "UmVzb3VyY2U6MQ==" -g "Contractors" -e "2026-12-31T23:59:59Z"
# Remove access for a group or service account
tgcli resource access remove -i "UmVzb3VyY2U6MQ==" -g "R3JvdXA6MQ=="
tgcli resource access remove -i "UmVzb3VyY2U6MQ==" -g "Engineering" -s "ci-bot"
# Replace ALL access with exactly this list (destructive: anything not listed loses access)
tgcli resource access set -i "UmVzb3VyY2U6MQ==" -g "R3JvdXA6MQ==,R3JvdXA6Mg=="
# Remove ALL access. `access set` with nobody named is refused; --clear says you mean it
tgcli resource access set -i "UmVzb3VyY2U6MQ==" --clear
Names or IDs. -r, -p, -g and -s on resource create and the three resource access commands accept either an ID or the object's exact name (capitals matter). Several values can be comma-separated or you can repeat the flag (-g Engineering -g Ops).
- An ID is used as given, with no extra request, so scripts that pass IDs behave exactly as before. A name costs one extra lookup per kind (a fraction of a second).
- A name that matches nothing, or matches more than one object, is an error and nothing is changed. All the problems are listed together, and an ambiguous name shows the matching IDs so you can pick one.
- A name containing a comma cannot be given by name (the comma splits the list); use its ID.
- Other commands, including
ssh-resource,webapp-resource,k8s-resourceand the rest ofresource, still take IDs only.
Create a resource and give groups access. A new resource can be reached by nobody until a group (or service account) has access, so there are two ways to do it:
# One step: create the resource and give groups access. -p is the resource's own Security Policy
tgcli -s acme resource create -a app.internal.example.com -n "Internal App" \
-r "Cloud Applications" -p "Default Policy" -g "Engineering,Ops"
# Two steps, when you need a different policy per group, service accounts, an expiry or autolock
tgcli -s acme resource create -a app.internal.example.com -n "Internal App" -r "Cloud Applications" -p "Default Policy"
tgcli -s acme resource access add -i <the id it printed> -g "Engineering" -p "Strict Policy"
tgcli -s acme resource access add -i <the id it printed> -g "Ops" -e "2026-12-31T23:59:59Z"
If resource create is run without -g, it prints a reminder to stderr (so piped JSON/CSV on stdout is unaffected) with the exact access add command for the new resource. To find a resource's ID later, use tgcli resource list; tgcli resource show -i <id> lists who has access and when each grant expires.
Auto-lock and the access policy. Auto-lock is set through the API's accessPolicy (mode AUTO_LOCK, duration in seconds; resource autolock and access add|set -a convert your days for you). It replaces the API's deprecated usageBasedAutolockDurationDays, which this CLI no longer sends or selects, so resource list and resource show JSON no longer contain that key: read accessPolicy.mode and accessPolicy.durationSeconds (resource list, resource list --detail and resource show all have them). -a must be 1 to 365 days (the API silently stores anything longer as 365, so the CLI refuses it); 0, negative numbers and anything over 365 are refused (resource autolock -a -1 turns auto-lock off). Auto-lock (-a) and expiry (-e) apply to groups only: the API rejects both for a service account, so the CLI refuses the call instead of sending it (add the service account in its own call without them). The CLI only sets AUTO_LOCK; other modes the API has (ACCESS_REQUEST) are not yet settable here.
Expiring access. A group's access to a resource can be given an expiry, after which the access ends by itself:
tgcli resource access add -i "UmVzb3VyY2U6MQ==" -g "Contractors" -e "2026-12-31T23:59:59Z"
- The expiry must be in the future and less than a year away (the API refuses anything else), and it applies to groups only: the API refuses an expiry, and auto-lock, for a service account, so the CLI refuses the combination before sending it. Running
access add -eagain for a group that already has access sets or changes that group's expiry; running it without-eleaves the existing expiry alone. To take an expiry off, remove the group's access (access remove) and add it again without-e. - To see it, use
tgcli resource show -i <id>(orresource list --detail):accessNamesandaccessExpiresAtare lists in the same order, and-f jsonhasexpiresAton each entry ofaccess.edges. The defaultresource listshows each grant's expiry inside itsaccess.edgescolumn too.
Changing access safely. access add only adds. access set replaces the whole list, so anything you leave out loses access; to protect against a typo it refuses to run when you name nobody, and removing everyone's access takes the explicit --clear. access add and access remove likewise require at least one group (-g) or service account (-s). The older spellings access_add, access_set and access_remove still work (they are the same commands, no longer shown in --help), so existing scripts keep running.
Protocol flags for resource create:
| Flag | Description | Default |
|---|---|---|
-t |
TCP policy: ALLOW_ALL or RESTRICTED |
ALLOW_ALL |
-c |
TCP port ranges JSON, e.g. [[22,22],[443,446]] |
[] |
-u |
UDP policy: ALLOW_ALL or RESTRICTED |
ALLOW_ALL |
-d |
UDP port ranges JSON | [] |
-i |
Disable ICMP | false |
-m |
Routing mode: THROUGH_TWINGATE or BYPASS_TWINGATE |
THROUGH_TWINGATE |
Gateway-backed resources
SSH, Web App and Kubernetes resources have their own create/update commands. List, show, delete and enable/disable them with the regular resource commands.
# SSH — a Gateway is required
tgcli ssh-resource create -n "Bastion" -a "bastion.corp.internal" -r "<network-id>" -w "<gateway-id>" \
--upstream-port 22 --downstream-port 22
tgcli ssh-resource update -i "<resource-id>" --active false
# Web app — gateway and both ports are required; TLS modes and header rewrites are optional
tgcli webapp-resource create -n "Grafana" -a "grafana.corp.internal" -r "<network-id>" -w "<gateway-id>" \
--upstream-port 3000 --upstream-tls NONE --downstream-port 443 --downstream-tls TLS13 \
--header "X-Forwarded-User=twingate"
tgcli webapp-resource update -i "<resource-id>" --upstream-port 8443 --upstream-tls VERIFY_FULL
# Kubernetes — gateway is optional
tgcli k8s-resource create -n "prod-cluster" -a "k8s.corp.internal" -r "<network-id>" \
--clusterref prod --upstream-port 6443 --downstream-port 443
Shared options: -l alias, -p policy ID, -g group IDs, -v visible (true/false). On update, only the options you pass are changed, and these are also available: -n name, -a address, -r network, -w gateway, --active true|false, --add-groups, --remove-groups.
- TLS:
--upstream-tlsisVERIFY_FULL,VERIFY_CA,INSECUREorNONE;--downstream-tlsisTLS13orNONE. Onupdate, a TLS option needs its matching--...-port. - On
webapp-resource update,--headerreplaces the whole list of header rewrites. - Not exposed here: protocol/port restrictions, tags, access-policy modes and approver groups.
connector
Manage Twingate connectors.
# List / show
tgcli connector list
tgcli connector show -i "Q29ubmVjdG9yOjE="
# Create a connector in a remote network
tgcli connector create \
-i "UmVtb3RlTmV0d29yazox" \
-c "my-connector" \
-s true
# Rename a connector
tgcli connector rename -i "Q29ubmVjdG9yOjE=" -n "new-name"
# Generate new access/refresh tokens
tgcli connector generateTokens -i "Q29ubmVjdG9yOjE="
# Enable/disable status email notifications
tgcli connector updateNotifications -i "Q29ubmVjdG9yOjE=" -s true
tgcli connector updateNotifications -i "Q29ubmVjdG9yOjE=" -s false
# Delete a connector (a deployed connector stops working; no confirmation prompt)
tgcli connector delete -i "Q29ubmVjdG9yOjE="
group
Manage Twingate groups and their members.
# List / show
tgcli group list
tgcli group show -i "R3JvdXA6MQ=="
# Create a group (members, resources, and security policy are optional)
tgcli group create -g "Engineering"
tgcli group create -g "DevOps" \
-u "VXNlcjox,VXNlcjoy" \
-r "UmVzb3VyY2U6MQ==,UmVzb3VyY2U6Mg==" \
-p "U2VjdXJpdHlQb2xpY3k6MQ=="
# Delete a group
tgcli group delete -i "R3JvdXA6MQ=="
# Add / remove users
tgcli group addUsers -g "R3JvdXA6MQ==" -u "VXNlcjox,VXNlcjoy"
tgcli group removeUsers -g "R3JvdXA6MQ==" -u "VXNlcjox"
# Add / remove resources
tgcli group addResources -g "R3JvdXA6MQ==" -r "UmVzb3VyY2U6MQ==,UmVzb3VyY2U6Mg=="
tgcli group removeResources -g "R3JvdXA6MQ==" -r "UmVzb3VyY2U6MQ=="
# Assign a security policy to a group
tgcli group assignPolicy -g "R3JvdXA6MQ==" -p "U2VjdXJpdHlQb2xpY3k6MQ=="
# Migrate all active Synced Groups to new Manual Groups (dry run by default)
tgcli group migrate
tgcli group migrate --execute
# Migrate only some groups: by ID or exact name, repeat --group for several
tgcli group migrate --group "Engineering"
tgcli group migrate --group "Engineering" --group "R3JvdXA6MQ==" --execute
# Customize the migration
tgcli group migrate --execute \
--name-suffix " (Manual)" \
--no-copy-security-policy \
--report my_report.csv
group migrate creates an empty Manual Group for each active Synced Group (named <name><suffix>), then adds the source group's members and resources in batches of 200. It copies the source group's Security Policy unless you pass --no-copy-security-policy. It reuses an existing Manual Group if one with the target name already exists (so a re-run does not create duplicates), skips inactive Synced Groups, and writes a timestamped CSV report of every group processed (or use --report for a custom path). The report path is checked before anything is read or created, so a path that cannot be written stops the command with nothing changed.
- A failure on one group is recorded in the report and the rest continue; the command then exits 1.
- Access settings are copied. A group's access to a single resource can have settings of its own: a different Security Policy, an access policy (auto-lock), an approval mode, or an expiry. After the resources are added,
group migrateputs those settings on the new group's access to each of those resources. Only grants that differ from a plain one need this, and the dry run and the report (grants_with_settings) say how many there are.--no-copy-security-policyleaves out those grants' Security Policies too. Reading them costs one request per resource the groups hold, so a migration of a large tenant takes a little longer. On a re-run, an existing Manual Group gets the same settings again, so a setting changed on that group's access since the first run is put back to the source group's. - Expired access is not migrated. If the source group's access to a resource has already expired, that resource is left out of the new group (adding it would give access that no longer expires) and the report's
warningscolumn names it. - With
--group, only the groups you name are read and migrated, and the report lists only those. Every name is checked before anything is read or written: a name that matches no group, matches several active Synced Groups (use the ID), or names a group that cannot be migrated (a Manual group, or an inactive one) stops the run with an error rather than being skipped. Without--group,--executeacts on every active Synced Group, so run the dry run first.
user
Manage Twingate users.
# List / show
tgcli user list
tgcli user show -i "VXNlcjox"
# Create a user and send an invitation email
tgcli user create \
-e [email protected] \
-f Alice \
-l Smith \
-r MEMBER
# Create without sending the invitation
tgcli user create -e [email protected] -l Jones -r DEVOPS -s False
# Update role (see the roles table below)
tgcli user role -i "VXNlcjox" -r ADMIN
tgcli user role -i "VXNlcjox" -r HELPDESK
# Enable or disable a user
tgcli user state -i "VXNlcjox" -s ACTIVE
tgcli user state -i "VXNlcjox" -s DISABLED
# Reset MFA
tgcli user resetmfa -i "VXNlcjox"
# Delete a user
tgcli user delete -i "VXNlcjox"
Roles (-r on user create and user role; case-insensitive):
| Role | Access |
|---|---|
ADMIN |
Full access to the Admin Console |
SUPPORT |
Read-only access to the Admin Console |
DEVOPS |
Full access to the Network tab, read-only access to the rest of the Admin Console |
INTERNET_SECURITY |
Full access to the Internet Security tab, read-only access to the rest of the Admin Console |
HELPDESK |
Read-only Admin Console access with MFA reset and device verification |
ACCESS_REVIEWER |
Access to Resource requests only |
BILLING |
Access to Billing functions only |
MEMBER |
No access to the Admin Console |
Which roles you can assign can depend on your Twingate plan.
network
Manage Twingate remote networks.
# List / show
tgcli network list
tgcli network show -i "UmVtb3RlTmV0d29yazox"
# Create a remote network
tgcli network create -n "AWS US-East" -a true -l AWS
tgcli network create -n "HQ Office" -a true -l ON_PREMISE
# Locations: AWS | AZURE | GOOGLE_CLOUD | ON_PREMISE | OTHER
# Delete a network
tgcli network delete -i "UmVtb3RlTmV0d29yazox"
# Activate / deactivate
tgcli network updateState -i "UmVtb3RlTmV0d29yazox" -a true
tgcli network updateState -i "UmVtb3RlTmV0d29yazox" -a false
# Rename
tgcli network updateName -i "UmVtb3RlTmV0d29yazox" -n "AWS EU-West"
# Update location
tgcli network updateLocation -i "UmVtb3RlTmV0d29yazox" -l AZURE
account
Manage Twingate service accounts.
# List / show
tgcli account list
tgcli account show -i "U2VydmljZUFjY291bnQ6MQ=="
# Create a service account (optionally pre-assign resources)
tgcli account create -n "CI/CD Bot"
tgcli account create -n "Monitoring" -r "UmVzb3VyY2U6MQ==,UmVzb3VyY2U6Mg=="
# Add / remove resources
tgcli account addResources -i "U2VydmljZUFjY291bnQ6MQ==" -r "UmVzb3VyY2U6Mw=="
tgcli account removeResources -i "U2VydmljZUFjY291bnQ6MQ==" -r "UmVzb3VyY2U6Mw=="
# Rename a service account
tgcli account rename -i "U2VydmljZUFjY291bnQ6MQ==" -n "New Name"
# List all keys for a specific service account
tgcli account listKeys -i "U2VydmljZUFjY291bnQ6MQ=="
# Delete a service account
tgcli account delete -i "U2VydmljZUFjY291bnQ6MQ=="
key
Manage service account keys.
# Show key details
tgcli key show -i "S2V5OjE="
# Create a key (expiration in days, 0 = never expires)
tgcli key create -n "deploy-key" -i "U2VydmljZUFjY291bnQ6MQ==" -e 90
tgcli key create -n "permanent" -i "U2VydmljZUFjY291bnQ6MQ==" -e 0
# Rename a key
tgcli key rename -i "S2V5OjE=" -n "new-key-name"
# Revoke a key (disables it but keeps the record)
tgcli key revoke -i "S2V5OjE="
# Permanently delete a key
tgcli key delete -i "S2V5OjE="
# Rotate a key: creates a new key on the same Service Account, then revokes the old one
tgcli key rotate -i "S2V5OjE="
tgcli key rotate -i "S2V5OjE=" -e 90 -n "deploy-key-2026Q3"
# List all keys across all service accounts, optionally filtered by status
tgcli key list
tgcli key list -s REVOKED
# List active keys expiring within N days (default 30)
tgcli key expiring
tgcli key expiring -d 7
key rotate prints the new key's secret token on standard output (progress goes to standard error, so -f json can be piped) — save it immediately, it can't be retrieved again. There's no native rotate mutation in the Twingate API; this orchestrates create-then-revoke. If creating the new key fails, the old key is left untouched. If the new key is created but revoking the old one fails, both stay active and the old key's ID is reported so you can revoke it manually.
policy
Manage Twingate security policies. The API only lets you change which groups a policy is assigned to, not the policy's rules.
# List all security policies
tgcli policy list
# Show a specific policy
tgcli policy show -i "U2VjdXJpdHlQb2xpY3k6MQ=="
# Assign / unassign groups
tgcli policy update -i "U2VjdXJpdHlQb2xpY3k6MQ==" --add-groups "R3JvdXA6MQ==,R3JvdXA6Mg=="
tgcli policy update -i "U2VjdXJpdHlQb2xpY3k6MQ==" --remove-groups "R3JvdXA6Mw=="
# Replace the policy's entire group assignment (cannot be combined with --add-groups/--remove-groups)
tgcli policy update -i "U2VjdXJpdHlQb2xpY3k6MQ==" --set-groups "R3JvdXA6MQ=="
# Remove EVERY group from the policy. --set-groups needs at least one ID; this is the explicit way to empty it.
# (The API ignores an empty list, so the CLI reads the policy's current groups and removes each one.)
tgcli policy update -i "U2VjdXJpdHlQb2xpY3k6MQ==" --clear-groups
access-request
Review and resolve resource access requests.
# List requests (all statuses by default; the API itself only returns PENDING when unfiltered)
tgcli access-request list
tgcli access-request list --status PENDING # --status: PENDING, APPROVED, REJECTED (repeatable)
tgcli access-request list --user-id "VXNlcjoxMjM=" --resource-id "UmVzb3VyY2U6NDU2"
# Show one request
tgcli access-request show -i "QWNjZXNzUmVxdWVzdDox"
# Approve / reject
tgcli access-request approve -i "QWNjZXNzUmVxdWVzdDox"
tgcli access-request reject -i "QWNjZXNzUmVxdWVzdDox"
gateway
Manage Gateways (Layer 7 privileged-access proxies) and see their remote network and certificate authorities.
tgcli gateway list
tgcli gateway show -i "R2F0ZXdheTox"
# Create (SSH CA is optional)
tgcli gateway create -n "UmVtb3RlTmV0d29yazox" -a "gateway.example.com:443" -x "<x509-ca-id>" [-s "<ssh-ca-id>"]
# Update — only the options you pass are changed
tgcli gateway update -i "R2F0ZXdheTox" -a "new.example.com:443"
tgcli gateway update -i "R2F0ZXdheTox" -s "<ssh-ca-id>"
# Delete (no confirmation prompt; the API reports success even if the ID doesn't exist)
tgcli gateway delete -i "R2F0ZXdheTox"
ca
Manage the X.509 and SSH Certificate Authorities that Gateways use.
tgcli ca list # both types, with a "type" column (X509 / SSH)
tgcli ca show -i "<ca-id>"
# Create from PUBLIC material only (a PEM certificate, or an SSH public key)
tgcli ca create -t x509 -n "Corp X509 CA" --file ./ca.pem
tgcli ca create -t ssh -n "Vault SSH CA" --file ./ssh_ca.pub
# Delete (type detected automatically; errors if the ID doesn't exist; no confirmation prompt)
tgcli ca delete -i "<ca-id>"
dnssec
Manage a single DNS Filtering profile's allow/deny domain lists. Every profile is identified by ID now that Twingate supports multiple DNS Filtering profiles per tenant — use dns-profile to find the ID.
# Show a profile's allow/deny lists
tgcli dnssec show -i "RG5zRmlsdGVyaW5nUHJvZmlsZTpiZmIzMjM="
# Set a profile's DNS allow list (replaces existing)
tgcli dnssec setAllowList -i "RG5zRmlsdGVyaW5nUHJvZmlsZTpiZmIzMjM=" -d "example.com,internal.corp"
# Set a profile's DNS deny list (replaces existing)
tgcli dnssec setDenyList -i "RG5zRmlsdGVyaW5nUHJvZmlsZTpiZmIzMjM=" -d "ads.example.com,tracking.io"
dns-profile
List DNS Filtering profiles and see which Groups (and therefore users) each one is assigned to.
# List all DNS Filtering profiles
tgcli dns-profile list
# Show a specific profile, including its allow/deny lists and assigned groups
tgcli dns-profile show -i "RG5zRmlsdGVyaW5nUHJvZmlsZTpiZmIzMjM="
# Create a profile (name only; the API applies its default settings)
tgcli dns-profile create -n "Contractors"
# Delete a profile (errors if the ID doesn't exist; no confirmation prompt; groups on it lose its filtering)
tgcli dns-profile delete -i "RG5zRmlsdGVyaW5nUHJvZmlsZTpiZmIzMjM="
To change a profile's allow/deny lists, use dnssec.
mappings
Analyse user-to-resource and user-to-network access mappings.
# Show which remote networks each user can reach
tgcli mappings user-network
# Show resources accessible to a specific user, optionally matched against an FQDN
# (also flags duplicate/ambiguous Resource definitions across Groups)
tgcli mappings user-resource -e [email protected]
tgcli mappings user-resource -e [email protected] -f app.internal.example.com
# Export all mappings to CSV
tgcli -f CSV mappings user-resource -e [email protected]
# Show which Resources have no live Connector in their Remote Network
# (joins Resources + Connectors client-side via Remote Network ID —
# nothing in the API links a Resource directly to a Connector)
tgcli mappings resource-connectivity
tgcli mappings resource-connectivity --offline-only
backup
Save the tenant's current state — groups, users, resources, connectors, remote networks, devices, service accounts (with their keys' metadata), Security Policies, DNS Filtering profiles, Gateways and Certificate Authorities — as JSON files. Read-only.
tgcli backup # ./twingate-backup-<tenant>-<UTC timestamp>/
tgcli backup -o ./backups/acme-weekly
tgcli -s staging backup # another saved session
It writes one file per type plus a manifest:
groups.json users.json resources.json connectors.json remote-networks.json
devices.json service-accounts.json security-policies.json dns-profiles.json gateways.json certificate-authorities.json
manifest.json
- Complete lists. The API returns at most 50 items per page, so every list, including nested ones such as a group's resources or a resource's access, is followed to its end. Each count is then checked against the API's own total; the manifest records the result, and the command exits non-zero if anything doesn't add up (the files are still written).
- Relationships are saved from both sides (a group lists its users and each user lists its groups), and IDs are kept, so a backup can be compared against a later one or used to rebuild state.
- Resources include their type, protocols, tags, access (groups and service accounts with their policy, access policy, approval mode and expiry), approver groups, and gateway/ports/cluster ref for Gateway-backed types. Deprecated API fields are not included.
- The newer types are devices (every state: active, archived and blocked, with their user's email and serial number), service accounts (with their resources and each key's name, status and dates), Security Policies (with the groups they apply to), DNS Filtering profiles (domain lists, category settings and groups), Gateways (with their Remote Network and CAs) and Certificate Authorities (
typeisx509orssh, with the fingerprint). If the API refuses one of them (a tenant without the feature, an API key without the right), that type is left out, the others are still written, the command names it asNOT READand exits non-zero, and the manifest records the problem. - Secrets are never included. The API does not return connector tokens or service account keys, and a key is listed by name, status and dates only.
- Privacy. The files contain user names and emails, and device names, host names, user names and serial numbers. On macOS and Linux the directory is created readable by you only (
0700/0600). On Windows those permission bits do not apply, so the files simply inherit the folder's access rights: save backups to a folder only you can read. Keep backups somewhere appropriate. - Safe to run unattended. It never overwrites: an existing output directory is refused. Files are written to
<dir>.partialand renamed once complete, so an interrupted run leaves no half-finished backup. - Not a transaction. Pages are read one after another, so changes made while it runs may or may not be included. Connector
lastHeartbeatAtchanges about once a minute, so backups taken minutes apart can differ there even if nothing was edited. - Not included: the device serial number allowlist, access requests and tenant-wide settings. A backup is a full snapshot every time: there is no incremental backup. To see what changed between two backups, use
tgcli backup diffbelow; to put a tenant back the way a backup had it, usetgcli restore; to copy a backup into another tenant, usetgcli import.
backup diff
Compare two backups of the same tenant: what was added, removed or changed between them. It works on the folders tgcli backup wrote, offline, so it needs no session and makes no API calls.
tgcli backup # run weekly, for example from cron
tgcli backup diff ./twingate-backup-acme-20261001T090000Z ./twingate-backup-acme-20261008T090000Z
tgcli backup diff old new --json # the same, as JSON
tgcli backup diff old new --exit-code # exit 1 when they differ (0 when they match)
groups: 0 added, 0 removed, 2 changed
~ Contractors
+ users: [email protected]
- resources: Wiki
resources: 1 added, 0 removed, 1 changed
+ Build server
~ Wiki
~ access[Contractors].approvalMode: MANUAL -> AUTOMATIC
+ tags: owner=it
- Matched by ID. A renamed group is one change to its name, not one group removed and another added. Lists inside an object (a group's members, a resource's access, its tags) are matched the same way, so a different order is not a change and a changed setting is reported on the grant it belongs to. A name that two objects share is shown with its ID.
- Fields that change on their own are left out:
updatedAt, a connector's last heartbeat and a device's last login. A real change always shows in some other field.--include-volatilecompares them anyway. - It compares state, not events. It cannot say who made a change or when, and something changed and changed back between two backups does not appear. The Admin Console's audit log is the place for that.
- Both folders must be backups of the same tenant. It warns when a backup was not fully verified, when a file no longer matches its checksum in the manifest, or when the folders are given newest first. A type only one backup holds (an older backup has no
devices.json, for example) is named as not compared. - Exit codes.
0when the comparison ran. With--exit-code,1when the backups differ.2when the folders cannot be compared (not a backup, different tenants, unreadable files). The report goes to standard output and warnings to standard error. - The report contains user emails and device details, like the backups, so keep it somewhere appropriate.
restore
Put a tenant back the way one of its own backups had it: Remote Networks, groups, resources of every type (network, SSH, web app and Kubernetes), service accounts, Gateways, Connectors and DNS filtering profiles, with their settings, members and access; and, when you ask for them, devices and users' roles. A dry run by default: nothing is changed until you add --execute.
tgcli -s acme restore ./twingate-backup-acme-20261001T090000Z # dry run: what is different, and what would be done
tgcli -s acme restore ./twingate-backup-acme-20261001T090000Z --execute # snapshot the tenant, then repair it
tgcli -s acme restore ./backup --execute --only group:Engineering --only resource:Wiki # repair just these
tgcli -s acme restore ./backup --execute --prune # also take out what the backup does not have
tgcli -s acme restore ./backup --execute --include devices --include roles # also put devices' trust and state, and users' roles, back
restore is for repairing damage to the same tenant. To copy one tenant into another, use tgcli import: IDs differ between tenants, so a restore would match nothing, and it refuses a backup of a tenant with a different name (--force-tenant overrides that if the tenant was renamed).
- Matched by ID. A group that was renamed is renamed back, a resource that was edited, disabled, moved to another Remote Network or given a different address is put back, a member who was removed is added back, and access that was taken away (or changed: its Security Policy, auto-lock, approval mode, expiry) is given back. For SSH, web app and Kubernetes resources that includes the Gateway, both ports, the TLS modes, the cluster reference and the request headers; for every resource type, its approver groups. A renamed service account, Gateway (its address, Remote Network and certificate authorities), Connector (its name and status notifications) or DNS profile (priority, fallback method, allowed and denied domains, the three category settings and the groups it is assigned to) is put back too. Group members are matched by email, so a user who was made again by your identity provider is still found.
- Deleted objects are created again. A deleted group, resource (of any type), Remote Network, service account, Gateway, Connector or DNS profile gets a new ID, so the access, members, Security Policy, approvers and Gateway it had are given back by name, in the right order (networks, groups, service accounts, Gateways, Connectors, resources, DNS profiles). An object that was deleted and made again by hand is taken to be the original when exactly one object fits its name (and Remote Network and address for a resource); if more than one fits, that row is blocked rather than guessed.
- Nothing is ever deleted. What was created after the backup is left alone (the output counts it).
--prunealso removes group members, resource access and tags that the backup does not have, but only from objects the backup has, and it never deletes an object. - What it will not do. It never touches Synced Groups (their names and members belong to your identity provider). It never clears a setting the backup did not have, and never makes an expired grant permanent. A reference that cannot be resolved (a Security Policy or service account that no longer exists) is named in the row and left out; a deleted group whose Security Policy is gone is blocked, because creating it without the policy would give it a different one. A resource that was deleted and made again as a different type only gets its access back, because the API cannot change a type.
- A safety snapshot comes first. With
--executethe tenant is read in full and written as a backup (twingate-prerestore-<tenant>-<timestamp>, or--snapshot-dir) before the first change, and the command prints how to undo the restore:tgcli restore <snapshot> --execute.--no-snapshotskips the snapshot (the tenant is still read in full). Nothing is written if the snapshot or the read fails or does not add up. - It says what it did not look at. A type the backup does not hold (an older backup has no
devices.json) and what restore cannot do are named under "Not compared" and "Not touched". A backup whose files no longer match their checksums is refused for--execute, and a backup older than 30 days is flagged. - Dry run first, then re-run. The dry run prints each change and writes a report (
restore_report_<timestamp>.csv). Applying the same restore twice is safe: after a successful one, the dry run finds nothing. The API allows about 20 writes a minute, so a big restore pauses when it is rate limited; if it gives up part way, run the same command again to finish. - What cannot come back. A service account's keys (a backup never holds the secrets): the row says to create new ones with
tgcli key create. A Connector made again is a new, empty Connector, so it needs tokens and has to be deployed again. A Gateway is only made again when its certificate authorities still exist (found by ID, or by fingerprint if they were made again); a certificate authority can only be made from its certificate file, so otherwise the Gateway, and the resources that go through it, are blocked with the command to run first (tgcli ca create). A deleted Security Policy is only named, because the API has no way to create one; a group's own policy is restored with the group. A device that is gone registers again when its user signs in. - Devices and roles are opt-in. Putting a device's trust, blocked or archived state, or a user's role, back undoes security decisions made since the backup (a device blocked after an incident would be unblocked), so a plain restore only says how many differ. Add
--include devicesand/or--include rolesto restore them; each change is listed first like any other, a role change that makes someone an administrator is flagged, and the API refuses to untrust a device whose serial number is verified (that row is an error and the device is left as it was). - Lists are merged, not replaced. The API replaces a DNS profile's domain lists and groups, and a web app's request headers, as a whole, so without
--prunewhat the object has now is sent along with the backup's (nothing is cleared); with--pruneit ends up with exactly the backup's. --onlytakesgroup:,resource:,network:,service-account:,gateway:<address>,connector:,dns-profile:,device:anduser:<email>.- Exit codes.
1if anything failed or is blocked (in a dry run: would be blocked), or a guard stopped it;0otherwise. - The snapshot and the report contain user emails, like any backup.
import
Create Remote Networks, groups (with their members), service accounts, certificate authorities, Gateways, resources (network, SSH, web app and Kubernetes, with group and service account access and approver groups) and DNS filtering profiles, and set device trust by serial number, from a file. A dry run by default: nothing is changed until you add --execute.
tgcli -s staging import ./twingate-backup-acme-20261023T101500Z # dry run, writes import_report_<timestamp>.csv
tgcli -s staging import ./twingate-backup-acme-20261023T101500Z --execute # apply it
tgcli -s staging import ./my-files --report ./plan.csv # files you wrote yourself
tgcli -s staging import ./groups.csv # one file
tgcli -s staging import ./my-files --sync # also bring what already exists in line with the file
tgcli -s staging import ./my-files --sync --prune # ... and remove what the file leaves out
tgcli -s staging import ./twingate-backup-acme-20261023T101500Z --ca-files ./certs # also make the backup's certificate authorities, from these files
The input is a folder, or a single file, named remote-networks, groups, service-accounts, certificate-authorities, gateways, resources, dns-profiles and devices, as .json or .csv (one or the other per name; certificate-authorities and dns-profiles are JSON only). A tgcli backup folder works as it is, so a backup of one tenant can be loaded into another; the other files in it (users, connectors, Security Policies and the manifest) cannot be carried over and are listed in the Not carried over section at the end of the run and in the report. A backup's devices.json is an inventory, so it is ignored too: it would otherwise block on devices that have no serial number and change trust in another tenant. To set device trust, write your own devices.csv (or point import at a devices.json file by itself). To write the files by hand, copy the templates in examples/import and edit them; only resources.csv has example rows (two network resources, and one each of SSH, web app and Kubernetes), so a copy never creates anything you did not add.
How it matches things
- Remote Networks, groups, service accounts and DNS profiles by exact name, group members by email, Security Policies by name, resources by name, Remote Network and address together, Gateways by address and Remote Network, and certificate authorities by fingerprint. IDs in a backup belong to the tenant it came from and are never used.
- Anything that already exists is left exactly as it is and only added to: missing group members, missing resource access. Nothing is ever removed or changed (unless you add
--sync, below), so you can run the same import again after a failure and it finishes the job, and a second run on a finished import does nothing. - Synced Groups are never created, changed or given members (their members belong to your identity provider). A resource can still be given access to one.
- A name that matches more than one object is never guessed: the row is blocked (or, for an optional extra, left out with a warning). Real tenants can hold several resources with the same name, Remote Network and address; those cannot be told apart, so the second and later ones in a file are blocked (create them by hand).
Bringing what exists in line with the file: --sync, and --prune
By default an import only adds. --sync also changes an existing group, resource or grant to match what the file says; --prune (which needs --sync) also removes what the file leaves out. Both are dry runs until you add --execute, and a dry run with --prune prints how many members and access grants it would remove.
- Only what the file states is compared. A blank CSV cell, a CSV column you left out, or a JSON key that is missing says nothing about that setting, so
--syncleaves it alone. In particular a blanktcp_portsis not "all ports" here (write*to say that), and a backup that has no Security Policy for a resource does not take the tenant's policy away. Settings are only ever set to what the file says, never cleared. - Resources (found by name, Remote Network and address, so those never change): alias, TCP and UDP ports, ICMP, visibility, browser shortcut, routing mode, Security Policy, access policy and approval mode (for an SSH, web app or Kubernetes resource: its Gateway, ports, TLS modes, cluster reference and request headers instead of the ports, ICMP, routing mode and browser shortcut). Only the settings that differ are sent. Changing the access policy also re-sends the approval mode, because the API can reset it to
MANUALotherwise. - A group's access to a resource that already exists: its own Security Policy, access policy, approval mode and expiry, for the settings the file states (JSON file, or
Group=Policyin a CSV). A past expiry is a warning, never applied. Service accounts' access has no settings, so there is nothing to sync. - Groups: a Manual group's Security Policy. Members are still only added. Synced Groups are never changed, with or without these flags.
--pruneremoves, for a group whose members the file lists, the members it leaves out; for a resource whose access the file lists, the group and service account access it leaves out; for a resource whose approver groups the file lists, the other approver groups; and for a resource whose tags the file lists, the other tags (without--prunetags are only added or updated). A blank members or groups cell in a CSV lists nothing, so it removes nothing; in a JSON file,"users": []or"access": []does mean "nobody", so under--pruneit empties the group or the resource's access. An access entry or member that the file names is never removed, even when it could not be added (an expired grant, an ambiguous name). Within a row things are added or changed before anything is removed, so a run that stops part way never leaves less access than the file describes.--prunenever deletes a group, resource, Remote Network or user. It removes group members, resource access and tags only.- A dry run with
--syncreads each existing resource's settings and access, so it makes more requests (and takes longer on a big file) than one without.
Carrying a backup into another tenant
Besides networks, groups and resources, an import also makes these when the folder (a backup, or files you wrote) holds them. Each one that already exists is found by the key above and left as it is (--sync does not cover them yet), so a second run does nothing.
- Service accounts (
service-accounts.json, orservice-accounts.csvwith anamecolumn) are created without keys: the API never returns a key's secret, so a backup cannot carry one. Create new keys withtgcli key create. A resource in the same file that grants access to the service account follows it. - Certificate authorities (
certificate-authorities.json). A backup holds a CA's name and fingerprint, never its certificate or public key, so a CA is only made from a file you give with--ca-files(one file, or a folder of PEM certificates and SSH public keys). The importer works out each file's own fingerprint and uses a file only for the CA whose fingerprint it has; a file that matches none is never sent, and after the CA is made the fingerprint the tenant reports is checked against the backup's too. A file holding a private key is refused and never read. A CA that already exists in the tenant (same fingerprint, even under another name) is reused. A CA with no matching file is skipped, and any Gateway that needs it is blocked until you supply the file. - Gateways (
gateways.json, orgateways.csvwithaddress,network,x509_caandssh_cacolumns holding CA names) are created in their Remote Network with the X.509 (and, if the backup says so, SSH) CA, found in the tenant by fingerprint (or by name, in a CSV). A resource that names the Gateway by address is created through it in the same run. This only registers the Gateway in the tenant: the Gateway software still has to be deployed, with its own keys. - DNS profiles (
dns-profiles.json) are created with their priority, fallback method, allowed and denied domains and category settings, and put on the groups they named (by name; a group that does not exist is left off, with a warning). A profile that already exists is not changed. - Approver groups are imported for every resource type:
approverGroupsin the backup's JSON, or anapprover_groupscolumn inresources.csv(group names separated by;). An existing resource is only added to, and--sync --prunealso removes the approvers the file leaves out (never for a resource whose file does not state them).
Not carried over. Everything in a backup that cannot be recreated in another tenant is listed at the end of the run and as not carried over rows in the report, with how many there are and what to do instead: users (they come from the identity provider; group members are matched by email to users who already exist), Connectors (each needs its own tokens and a deployment: tgcli connector create), Security Policies and their rules (the API cannot create them, so a group, resource or grant that names one needs it to exist already), device registrations (a device registers itself; devices.csv sets trust on devices that have), service account keys, and certificate authorities for which no file was given. Only what the backup actually holds is listed.
What a row can end up as
| Status | Meaning |
|---|---|
would-create / would-update (dry run), created / updated |
What was, or would be, done (the detail says what: members or access to add, settings to change, access or members to remove) |
exists |
Already there, nothing to add |
skipped |
Deliberately not imported (Synced and System groups, a resource type this tool does not know) |
blocked |
Cannot be made correctly, for example its Remote Network or Security Policy does not exist, or a port list is invalid. Nothing is sent for it |
error / not-run |
The API refused it, or the run stopped because the credentials were rejected |
A row with a warning still goes ahead, but something optional was left out and the report says what: a member whose email is not in this tenant, access for a group or service account that does not exist, a per-group policy that does not exist (it is not replaced by the default), access that has already expired (it is never restored as permanent access). Warnings do not change the exit code; blocked and error rows exit 1, also on a dry run, so a script can check before applying.
Every row, with its status, detail, warnings, error, the new ID and where in the file it came from, is written to the CSV report (--report, default import_report_<UTC timestamp>.csv). The report path is checked before anything is read from or written to the tenant, so a path that cannot be written stops the command with nothing changed.
CSV columns (the first row is the header; unknown columns are an error, so a typo cannot silently drop access)
| File | Columns |
|---|---|
remote-networks.csv |
name (required), location (AWS, AZURE, GOOGLE_CLOUD, ON_PREMISE, OTHER), network_type (REGULAR, EXIT), active |
groups.csv |
name (required), members (emails separated by ;), security_policy |
devices.csv |
serial_number (required), trusted (default true) |
service-accounts.csv |
name (required) |
gateways.csv |
address, network, x509_ca (all required), ssh_ca: the CAs by name |
resources.csv |
name, network, address (all required), alias, tcp_ports, udp_ports, allow_icmp, visible, security_policy, groups, service_accounts, routing_mode, approver_groups; and, for SSH, web app and Kubernetes resources only, type, gateway, upstream_port, downstream_port, upstream_tls, downstream_tls, cluster_ref, headers |
tcp_ports/udp_ports:*or blank means every port,nonemeans no ports, otherwise a list like80;443;8000-8100.groups: group names separated by;, each optionally followed by=<Security Policy>for a per-group policy, for exampleEngineering=Strict Policy;Ops. A group name containing;or=needs the JSON format.approver_groups: group names separated by;, the groups that approve access requests (a blank cell states nothing).service_accounts: service account names separated by;, for exampleci;deploy. They must already exist, or be in the same import'sservice-accountsfile, and a service account's access takes no per-grant settings: the API refuses a Security Policy, auto-lock, approval mode or expiry on it. A row from a JSON file that does carry one of those for a service account is left out of the grant with a warning, because a plain grant would be wider than the file says.- True/false columns accept
true/false,yes/noand1/0. Empty cells use the API's defaults.
SSH, web app and Kubernetes resources. These are served through a Gateway, and are imported from the backup's JSON as they are, or from resources.csv with the extra columns. A blank type is a network resource; otherwise write ssh, web app (or webapp) or kubernetes (or k8s).
| Column | For | Meaning |
|---|---|---|
gateway |
all three (SSH and web app need it) | The address of the Gateway, exactly as tgcli gateway list shows it |
upstream_port / downstream_port |
all three (a web app needs both) | The port on the target host, and the port clients connect to |
upstream_tls |
web app | Gateway to the web app: VERIFY_FULL, VERIFY_CA, INSECURE or NONE |
downstream_tls |
web app | Client to the Gateway: TLS13 or NONE |
cluster_ref |
Kubernetes | The cluster reference |
headers |
web app | Request header rewrites, Key=Value separated by ; (a value containing ; needs the JSON format) |
- A Gateway has no name, so it is found by its address. It must exist in the tenant, or be made earlier in the same import by a
gatewaysfile (see above; making one needs its certificate authority, whose certificate a backup does not hold, so that needs--ca-files). You can also create it yourself withtgcli gateway createfirst. The Gateway's ID in a backup is not used. If the address is not found the row isblockedand says so; if two Gateways share an address, the resource's Remote Network decides, and if it cannot the row is blocked. When you load a backup into another tenant, make the Gateways there have the same addresses, or change thegatewayaddress in the file. - A resource's type cannot be changed. If a resource with the same name, Remote Network and address already exists as a different type, the row is
blocked. - A row that fills a column its type does not take (
tcp_portson an SSH resource,cluster_refon a web app, any gateway column on a network resource) isblocked, so nothing is silently dropped. The ports, ICMP, routing mode and browser shortcut that the API reports for these types in a backup are ignored: the API cannot set them there. - Access, Security Policy, access policy, approval mode, visibility, alias and tags work as for network resources. With
--syncthe Gateway, both ports and TLS modes (a TLS mode the file does not state is kept), the cluster reference and the request headers are compared too; the headers are merged by key, and with--prunereplaced by exactly the file's list.
Device trust by serial number. devices.csv (or devices.json with serialNumber and isTrusted) sets trusted on devices that have already registered with the tenant; the serial number is matched ignoring case.
- Only devices that are not archived are changed. A serial number that has no device yet, or only archived ones, is
skipped; to trust a serial number before its device registers, add it to the allowlist withtgcli device snumber add. - One serial number can belong to several devices. Making them trusted is only done when they all belong to one user: if the matching devices belong to several users (some VMs and hardware report a generic serial), the row is
blockedand you pick the device withtgcli device updateTrust -i. Making devices untrusted applies to all of them. - After each change the API's reply is checked: if it says the device did not end up with the trust you asked for, the row is an
error. - The API refuses to untrust a device whose serial number is verified ("Serial number verified Devices cannot be unverified via API"). That row is an
errorshowing the API's message, and the device is left as it was.
Imported from the backup's JSON: Remote Network location, type and active state; group name, Security Policy and members; service account names; certificate authorities (from files); Gateways; DNS profiles; for each network resource its address, alias, protocols and ports, visibility, browser shortcut, routing mode, tags, Security Policy, access policy and approval mode; for each SSH, web app and Kubernetes resource the same except protocols, browser shortcut and routing mode, plus its Gateway (by address), ports, TLS modes, request headers and cluster reference; and for every resource its approver groups and the access of each group (with its own policy, access policy, approval mode and expiry) and of each service account.
Not imported (see Not carried over above; each is reported, never silently dropped): users, Connectors, Security Policies and their rules, device registrations, service account keys, and device posture checks.
Rate limits. The API typically allows about 20 writes a minute. A dry run tells you how many write requests a large import would make; the run pauses when rate limited, and if it gives up part way, run the same command again to finish.
Encoding: reports are UTF-8, and input files can be UTF-8 (with or without a byte order mark) or UTF-16. Excel may show non-English characters wrongly if you double-click the report; use Data > From Text/CSV and choose UTF-8.
remove
Remove the resources, groups and Remote Networks a file names. This is the most dangerous command in the CLI, so it shows you the list first and never removes anything on its own: a dry run by default, and --execute still stops to ask you to type how many objects will go.
tgcli -s acme remove ./old-stuff.csv # dry run: the list, what hangs off each object, a report
tgcli -s acme remove ./old-stuff.csv --execute # show the list again, ask you to type the count, snapshot, remove
tgcli -s acme remove ./group-names.txt --kind group # a plain list: one name or id per line
The file is a CSV or JSON list with a kind (resource, group or network) and an id and/or a name per line; a resource line may also give network and address to tell apart resources that share a name. A plain .txt list of names or ids needs --kind. Copy the template in examples/remove.
kind,id,name,network,address
group,,Contractors 2024,,
resource,,Wiki,HQ,wiki.internal
network,,Old Datacentre,,
- Each line must match exactly one object. A name must match exactly (capitals included). A name that matches several objects, an id whose object has a different name than the line says (the file is out of date), or a line that cannot be read blocks the whole run: if any line is blocked, nothing at all is removed, even with
--execute. Name an object by its id to settle a duplicate. - The list shows what depends on each object. A resource: its network and address and how many groups or service accounts lose access. A group: how many members, how many resources it has access to, resources that would be left with nobody who can reach them, and resources it approves access requests for.
- Never removed: Synced Groups and the System group (they belong to your identity provider). A Remote Network is only removed when every resource in it is in the file too, and never while it still has Connectors or Gateways (remove those first with
tgcli connector delete/tgcli gateway delete). Everything else, such as users, connectors, devices, service accounts and Security Policies, is not something this command removes. - Confirmation you cannot click through. With
--executethe list is printed and you must type the number of objects to be removed (Type 14 to confirm). Anything else removes nothing. There is no--yes, and with no terminal to ask on (a script, cron job or pipe) the command refuses. - A safety snapshot comes first. After you confirm and before the first removal, what was read (Remote Networks, groups, resources, users, Security Policies and service accounts) is written as a backup (
twingate-preremove-<tenant>-<timestamp>, or--snapshot-dir) and the command prints how to put it back:tgcli restore <snapshot> --execute. Restore creates removed groups, resources and Remote Networks again, but with new IDs (what refers to the old IDs, such as scripts, needs updating).--no-snapshotskips the snapshot. - A line that matches nothing is skipped and reported as
not-found(it is already gone, or the name has a typo: check the list). That is what lets a run that stopped part way be run again to finish. Objects are removed resources first, then groups, then Remote Networks; if a resource in a Remote Network could not be removed, that network is not attempted (not-run). - Report and exit codes.
remove_report_<timestamp>.csvlists every line with its status (removed,error,not-run,not-found,blocked; in a dry runto-remove). Exit1if anything is blocked, failed or was not attempted, or a guard stopped it;0otherwise, including a dry run that is clean. The API allows about 20 writes a minute, so a big removal pauses when rate limited. - The snapshot and the report contain user emails, like any backup.
Rate Limiting
The Twingate API enforces rate limits (typically 60 reads/min and 20 writes/min, which may vary by account). The CLI handles this automatically:
- When an HTTP 429 Too Many Requests response is received, a warning is printed to
stderrand the request is automatically retried after the wait time specified in the API'sRetry-Afterheader. - Up to 3 retries are attempted before the command fails with an error.
- If no
Retry-Afterheader is present, a default wait of 60 seconds is used.
Example warning output:
⚠ Rate limited by Twingate API. Waiting 42s before retrying (attempt 1/3)...
Development
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
python -m pytest
# Run tests without coverage report (faster)
python -m pytest --no-cov
Project layout:
src/tgcli/
├── client/ # HTTP client, session management, exceptions
├── commands/ # One file per command group (Typer apps)
├── output/ # Formatter + per-entity transformers
├── queries/ # GraphQL query/mutation constants
├── validators/ # Input validation helpers
└── utils/ # Shared utilities (FQDN matching etc.)
tests/
├── commands/ # Command-level integration tests (CliRunner)
├── conftest.py # Shared fixtures (mock keyring, sample data)
└── test_*.py # Unit tests
Explore related projects
codecrafters-io/build-your-own-x
Master programming by recreating your favorite technologies from scratch.
562,446 starspublic-apis/public-apis
A collective list of free APIs
498,916 starsmorluto/rea
Reverse engineer anything with agents, from app behavior down to native binaries.
458,790 starsopenclaw/openclaw
The AI that really does things. Any OS. Any Platform. The lobster way. 🦞
394,433 stars