WireGuard VPN manager with web UI and REST API. Auto-gen configs and QR codes.
2.8K
WireGuard VPN management application for creating and managing VPN servers and clients through a web UI and REST API.
Pull the image and start with Docker Compose:
docker pull nullata/nullguard:latest
Create a docker-compose.yml. This example uses MySQL/MariaDB, the default backend — see PostgreSQL and SQLite below for the alternatives:
services:
nullguard:
image: nullata/nullguard:latest
ports:
- "8080:8080"
# WireGuard UDP ports (add udp ports for each WireGuard server instance)
- "51820:51820/udp"
environment:
SERVER_PORT: "8080"
SERVER_SSL_ENABLED: false
DB_TYPE: mysql
DB_HOST: localhost
DB_PORT: 3306
DB_NAME: nullguard
DB_USER: nullguard
DB_PASS: changeme
# Optional values
SESSION_SECRET_KEY: ""
WG_SERVER_DEFAULT_NAME: wg0
WG_SERVER_DEFAULT_COMMENT: nullguard
WG_SERVER_DEFAULT_ADDR: 10.252.0.1/24
WG_SERVER_DEFAULT_PORT: 51820
WG_SERVER_DEFAULT_SUPERNET: 128.0.0.0/1, 0.0.0.0/1
WG_SERVER_DEFAULT_KEEPALIVE: 30
WG_SERVER_CONF_PATH: /etc/wireguard
WG_CLIENT_DEFAULT_NAME: my-wg-client
AUTO_START_SERVERS: false
COOKIE_SECURE: false
SESSION_MAX_AGE: 3600
ENV: production
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.ip_forward=1
To use PostgreSQL (DB_TYPE=postgres), point the DB_* variables at your Postgres server and optionally set DB_SSLMODE (default disable — a dev default; use require or verify-full for a remote server):
services:
nullguard:
image: nullata/nullguard:latest
ports:
- "8080:8080"
- "51820:51820/udp"
environment:
SERVER_PORT: "8080"
SERVER_SSL_ENABLED: false
DB_TYPE: postgres
DB_HOST: localhost
DB_PORT: "5432"
DB_NAME: nullguard
DB_USER: nullguard
DB_PASS: changeme
DB_SSLMODE: disable
# ... same optional values as above
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.ip_forward=1
Note one behavioral difference from MySQL: Postgres unique indexes are case-sensitive, so e.g. two admin usernames differing only by case are allowed on Postgres but not on MySQL's default collation.
To run without a database server, drop the five DB_* variables and use SQLite instead. Mount a named volume so the database file persists across container restarts and removals:
services:
nullguard:
image: nullata/nullguard:latest
ports:
- "8080:8080"
- "51820:51820/udp"
environment:
SERVER_PORT: "8080"
SERVER_SSL_ENABLED: false
DB_TYPE: sqlite
DATABASE_URL: /data/nullguard.db
# ... same optional values as above
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.ip_forward=1
volumes:
- sqlite_data:/data
volumes:
sqlite_data:
Note that switching an existing deployment between backends is not an in-app migration — copy the data separately (e.g. mysqldump/pg_dump), since AutoMigrate only handles fresh schemas.
Then run:
docker compose up -d
Access the web UI at http://localhost:8080. On first visit, you'll be prompted to set up an admin password.
⚠️ Important: Remember to configure port forwarding on your router to forward UDP port 51820 (or whichever ports you're using for WireGuard) to your Docker host's IP address. Without proper port forwarding, external clients won't be able to connect to your VPN.
The container requires:
nullGuard supports MySQL/MariaDB (default), PostgreSQL, and SQLite, selected with the DB_TYPE environment variable. Tables are created automatically on first run.
MySQL/MariaDB (DB_TYPE=mysql, the default) — create a database and user for nullguard:
CREATE DATABASE nullguard;
CREATE USER 'nullguard'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON nullguard.* TO 'nullguard'@'%';
FLUSH PRIVILEGES;
PostgreSQL (DB_TYPE=postgres) — point DB_HOST, DB_PORT, DB_NAME, DB_USER, and DB_PASS at your Postgres server. TLS is controlled by DB_SSLMODE (default disable, a dev default — set require or verify-full for remote servers). One behavioral difference from MySQL: Postgres unique indexes are case-sensitive, so e.g. two admin usernames differing only by case are allowed on Postgres but not on MySQL's default collation.
SQLite (DB_TYPE=sqlite) — no database server needed. The database file is created automatically at the path given by DATABASE_URL (default nullguard.db in the working directory).
| Variable | Description | Default |
|---|---|---|
SERVER_PORT | Port the app listens on | 8080 |
DB_TYPE | Database backend (mysql, postgres, or sqlite) | mysql |
DATABASE_URL | Path to the SQLite database file (only for sqlite) | nullguard.db |
DB_SSLMODE | Postgres TLS mode (only for postgres). disable is only a dev default — use require (or verify-full) for any non-local Postgres server | disable |
DB_HOST | Database host | (required for mysql/postgres) |
DB_PORT | Database port | (required for mysql/postgres) |
DB_NAME | Database name | (required for mysql/postgres) |
DB_USER | Database user | (required for mysql/postgres) |
DB_PASS | Database password | (required for mysql/postgres) |
SESSION_SECRET_KEY | Secret key for session encryption | (auto-generated) |
WG_SERVER_CONF_PATH | Path to store WireGuard config files | ./ |
AUTO_START_SERVERS | Auto-start all WireGuard servers on startup | false |
ENV | Set to production for secure session cookies | (empty) |
Optional defaults for new servers:
WG_SERVER_DEFAULT_NAME - Default server nameWG_SERVER_DEFAULT_ADDR - Default server addressWG_SERVER_DEFAULT_PORT - Default server portWG_SERVER_DEFAULT_KEEPALIVE - Default keepalive (0-600 seconds)WG_SERVER_DEFAULT_SUPERNET - Default supernet CIDRWG_SERVER_DEFAULT_COMMENT - Default server commentOptional defaults for new clients:
WG_CLIENT_DEFAULT_NAME - Default client namehttp://localhost:8080/setupcurl -X POST http://localhost:8080/api/v1/create-server \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"interfaceName": "wg0",
"address": "10.0.0.1/24",
"port": 51820,
"wanAddress": "vpn.example.com"
}'
curl -X POST http://localhost:8080/api/v1/deploy-server \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"serverId": 1,
"interfaceName": "wg0"
}'
curl -X POST http://localhost:8080/api/v1/create-client \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"serverId": 1,
"name": "my-laptop"
}'
curl -X POST http://localhost:8080/api/v1/restart-server \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"serverId": 1,
"interfaceName": "wg0"
}'
All API endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <your-api-token>
All responses follow this format:
{
"timestamp": "2025-01-01T12:00:00Z",
"status": "success",
"message": "Description",
"data": null
}
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/list-servers | List all servers |
| POST | /api/v1/create-server | Create a new server |
| POST | /api/v1/fetch-server | Get server details |
| PUT | /api/v1/update-server | Update a server (must be stopped) |
| DELETE | /api/v1/delete-server | Delete a server (must be stopped) |
| POST | /api/v1/deploy-server | Start a server |
| POST | /api/v1/stop-server | Stop a server |
| POST | /api/v1/restart-server | Restart a running server |
| POST | /api/v1/toggle-auto-restart | Enable/disable auto-restart on client changes |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/list-clients/{serverId} | List all clients for a server |
| POST | /api/v1/create-client | Create a new client |
| PUT | /api/v1/update-client | Update a client |
| DELETE | /api/v1/delete-client | Delete a client |
| GET | /api/v1/client/{serverId}/{clientId}/config | Get client config file |
| GET | /api/v1/client/{serverId}/{clientId}/qrcode | Get client QR code (PNG) |
| GET | /api/v1/client/{serverId}/{clientId}/download | Download client config |
POST /api/v1/create-server
{
"interfaceName": "wg0",
"address": "10.0.0.1/24",
"port": 51820,
"wanAddress": "vpn.example.com",
"publicKey": "", // Auto-generated if omitted
"privateKey": "", // Auto-generated if omitted
"postUp": "", // Optional iptables rules
"postDown": "", // Optional iptables rules
"supernetCidr": "", // Optional supernet CIDR
"bridgeNetworks": "", // Optional comma-separated CIDRs of server-side LANs clients may bridge into
"defaultKeepAlive": 30, // 0-600 seconds
"comment": "" // Optional description
}
POST /api/v1/create-client
{
"serverId": 1,
"name": "my-laptop",
"address": "", // Auto-assigned if omitted
"allowedIps": "", // Derived from server if omitted
"dnsServers": "8.8.8.8, 1.1.1.1", // Optional, max 2
"fullTunnel": false, // Route all traffic through VPN
"keepalive": 30, // 0-600 seconds, uses server default if omitted
"exposedLans": "" // Optional comma-separated CIDRs of LANs reachable through this client (LAN-to-LAN)
}
Response includes ready-to-use config:
{
"status": "success",
"message": "Client created",
"data": {
"config": "[Interface]\nPrivateKey = ...\n..."
}
}
serverId and interfaceName to prevent accidentsupdate-server, omitting an optional field (or sending it as "") clears the stored value. Applies to postUp, postDown, supernetCidr, bridgeNetworks, and defaultKeepAlive - always include the fields you want preservedbridgeNetworks lists server-side LANs that the web UI surfaces as checkboxes on each client form; ticking one appends the CIDR to that client's AllowedIPsexposedLans) - A client may declare CIDRs reachable through itself; those CIDRs are added to that peer's server-side AllowedIPs so other clients can route to them. Requires net.ipv4.ip_forward=1 and NAT/masquerade (or LAN-side static routes) on the exposing client's host - see the full README for the Ubuntu setup walkthrough"Server is active. Please stop the server before updating/deleting"
/api/v1/stop-server first."Server is not currently active"
restart-server endpoint requires a running server. Use deploy-server to start it.Client changes not taking effect
/api/v1/restart-server.Licensed under the Elastic License 2.0
Copyright 2026 nullata
Content type
Image
Digest
sha256:99c84c6e5…
Size
26.7 MB
Last updated
5 days ago
docker pull nullata/nullguard