A Dockerized Fortinet VPN gateway for a home server. shorti connects to a VPN via OpenConnect, handles password + TOTP authentication, keeps the tunnel alive with an auto-reconnect watchdog, and masquerades traffic so any device on your LAN can reach resources behind the VPN — without running its own VPN client.
It's the Linux/server counterpart to morti,
which does the same job interactively on macOS.
Your device (192.168.1.x)
│ traffic to 10.x.x.x (VPN subnet)
▼
Mikrotik router
│ static route: 10.0.0.0/8 → 192.168.1.50
▼
gateway host (192.168.1.50)
running shorti ── host network mode ──┐
│ iptables MASQUERADE + MSS clamp │
▼ │
VPN tunnel (tun0) ───────────────────────┘
▼
remote network (10.x.x.x)
A single privileged container runs OpenConnect in the background, supervised by a
bash watchdog (monitor.sh) that detects tunnel drops and reconnects. On each
successful connect, routing.sh installs iptables MASQUERADE rules (plus a TCP
MSS clamp for the tunnel's smaller MTU) so the gateway host forwards LAN traffic
through the tunnel. A tiny Python HTTP server exposes /health and /metrics for
polling.
The container runs with network_mode: host so the tunnel, routes, IP
forwarding, and NAT rules live in the host's network namespace — which is what
makes the gateway host's own IP a valid gateway for Mikrotik-routed traffic.
A multi-arch image (linux/amd64, linux/arm64) is published to GHCR on every
release, so you can skip the local build:
docker pull ghcr.io/alikarami/shorti:latestThe bundled compose.yml already points at it — just pull and start:
docker compose pull && docker compose up -dTags: latest, plus MAJOR, MAJOR.MINOR, and the full MAJOR.MINOR.PATCH for
each release (e.g. :1, :1.0, :1.0.0). To build from source instead, use
docker compose up -d --build.
-
Enable IP forwarding on the host (one-time). Because the container runs in host network mode, the kernel's forwarding setting belongs to the host and can't be set from Compose:
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-shorti.conf sudo sysctl --system
-
Configure. Copy the template and fill in your VPN details:
cp .env.example .env $EDITOR .envAt minimum you need
SHORTI_SERVER,SHORTI_USERNAME, a password source, and a TOTP source (see Configuration). -
Run.
docker compose up -d docker ps # STATUS shows (healthy) once the tunnel is up curl http://localhost:9798/health # → {"status":"connected"}
-
Route traffic. Point your Mikrotik (or any router) at the gateway host for the VPN's subnets — see
mikrotik/README.md.
All configuration is via environment variables in .env.
| Variable | Description |
|---|---|
SHORTI_SERVER |
VPN server hostname (e.g. vpn.example.com) |
SHORTI_USERNAME |
Your VPN username |
| Password — set one of: | |
SHORTI_PASSWORD |
Password as a literal value |
SHORTI_PASSWORD_CMD |
Command that prints the password (e.g. a secret manager) |
| TOTP — set one of (priority: CMD → SECRET_CMD → SECRET): | |
SHORTI_TOTP_CMD |
Command that prints a ready 6-digit code |
SHORTI_TOTP_SECRET_CMD |
Command that prints the base32 TOTP secret |
SHORTI_TOTP_SECRET |
Base32 TOTP secret as a literal value |
| Variable | Default | Description |
|---|---|---|
SHORTI_SERVERCERT |
— | Pin the server certificate (pin-sha256:…) |
SHORTI_REALM |
— | OpenConnect authgroup / realm |
SHORTI_EXTRA_ARGS |
— | Extra flags passed to openconnect (e.g. --no-dtls) |
SHORTI_PING_HOST |
— | Host to ping through the tunnel as a connectivity check (recommended) |
SHORTI_LAN_IFACE |
auto | LAN interface for forwarding (auto-detected from the default route) |
HEALTH_PORT |
9798 |
Port for the /health and /metrics endpoints |
SHORTI_MAX_RETRIES |
5 |
Connection attempts before giving up a cycle |
SHORTI_RETRY_DELAY |
5 |
Seconds between retries |
SHORTI_MONITOR_INTERVAL |
30 |
Seconds between tunnel health checks |
Secrets stay local —
.envis gitignored and never leaves the machine.
| Path | Response |
|---|---|
GET /health |
200 {"status":"connected"} or 503 {"status":"disconnected"} |
GET /metrics |
200 plain text: vpn_connected=0|1, vpn_reconnect_total=<n> |
Connects, then reconnects every ~30s (new PID each time). First check why the watchdog thinks the tunnel is down — it logs the failing health check:
docker compose exec vpn tail -n 60 /var/log/shorti/shorti.loghealth: pidfile empty/missingwhile openconnect is running means the watchdog can't read its own pidfile. The classic cause is the bash gotchapid="$(<"$f" 2>/dev/null)"— the extra redirection disables the$(<file)fast path and yields an empty string every time, so the check always fails. shorti reads it as[[ -f "$f" ]] && pid="$(<"$f")"instead.health: openconnect (pid N) not runningwhile openconnect is actually alive means the liveness check itself is broken (e.g. usingps -p, which busybox on Alpine doesn't support — shorti uses thekill -0builtin instead).- A genuine manual
openconnectrun that stays up while the daemon drops on the dot everySHORTI_MONITOR_INTERVALis the tell-tale sign the watchdog is the culprit, not the tunnel — check the health-check messages above first. health: ping <host> failedmeans routing toSHORTI_PING_HOSTis broken even though the tunnel is up — check the host route /SHORTI_PING_HOSTis reachable through the VPN.
If instead the log shows openconnect itself dropping the data channel, the usual cause with FortiGate is the DTLS heartbeat. If the log shows the data channel on DTLS and an unrecognized heartbeat packet, like:
Configured as 10.x.x.x, with SSL disconnected and DTLS established
...
Unexpected pre-PPP packet header for encap 5.
< 0000: 00 13 47 46 74 79 70 65 00 68 65 61 72 74 62 65 |..GFtype.heartbe|
then openconnect (even the current v9.12) doesn't answer FortiGate's heartbeat on the DTLS channel, so the server drops the session at its ~30s heartbeat timeout. Keep the data channel on TLS instead:
# in .env, then: docker compose up -d --force-recreate
SHORTI_EXTRA_ARGS=--no-dtlsOther knobs in SHORTI_EXTRA_ARGS if the log points elsewhere:
--no-http-keepalive, or an explicit MTU like -m 1300.
The
Cannot open "/proc/sys/net/ipv4/route/flush": Read-only file systemlines from the vpnc-script are harmless (Docker mounts/proc/sysread-only) and are not the cause of disconnects.
docker logs shows nothing about the disconnect. Fixed — openconnect now runs
in the foreground (backgrounded by the supervisor) so its full output, including
disconnect reasons, is written to /var/log/shorti/shorti.log instead of syslog.
The full test suite runs in a container — no real VPN required:
docker compose -f compose.test.yml run --rm test # 35 bats unit tests
bash tests/smoke.sh # build + boot + health checkTests are written with bats-core. Each
script under scripts/ is sourceable (functions only) and standalone, so its
helpers can be unit-tested in isolation.
| File | Responsibility |
|---|---|
scripts/entrypoint.sh |
Validate config, start health server, exec the monitor |
scripts/monitor.sh |
Watchdog loop: connect, verify, reconnect on drop |
scripts/connect.sh |
One-shot OpenConnect connect + tunnel verification |
scripts/totp.sh |
TOTP generation (oathtool, python3 fallback) |
scripts/routing.sh |
iptables MASQUERADE + MSS clamp setup/teardown |
scripts/health.sh |
Python HTTP server for /health and /metrics |
- A Linux host with Docker + Docker Compose v2
- IP forwarding enabled on the host:
net.ipv4.ip_forward=1(see Quick start step 0 — under host networking this must be set on the host, not in Compose) /dev/net/tunavailable and theNET_ADMINcapability (granted incompose.yml)- A router (e.g. Mikrotik) to route the VPN's subnets at the gateway host — optional if you only want the gateway on the host itself
MIT