-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdocker-compose.yaml
More file actions
327 lines (300 loc) · 15.8 KB
/
Copy pathdocker-compose.yaml
File metadata and controls
327 lines (300 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# Culvert Server
# OpenVPN 2.7+ with DCO and WireGuard in one image - each optionally
# tunnelled over HTTPS - plus OIDC SSO and external PKI
# Drop-in 'just works' docker or k8s scale deploy
#
# Authentication:
# - Certificate + OIDC hybrid authentication
# - Device identity via client certificate
# - User identity via OIDC (openvpn-auth-oauth2)
# - Group-based access control via your IdP (configure groups in IdP + OIDC claim)
#
# Deployment Modes:
# Single-node: docker compose up -d
# Scale-out: docker compose -f docker-compose.yaml -f docker-compose.shared.yaml up -d
#
# DCO (Data Channel Offload) Note:
# DCO moves encryption to kernel space for massive performance gains.
# The container accesses the HOST's DCO kernel module - ensure host VM has:
# - Linux kernel 6.x+ (Ubuntu 24.04 has 6.8)
# - openvpn-dco-dkms package installed on host
# - 'modprobe ovpn-dco-v2' run on host
#
# Usage:
# docker compose up -d # Start server
# docker compose logs -f # View logs
# docker compose down # Stop server
# docker compose exec openvpn generate-client --name alice # Generate client config (all protocols/modes)
# docker compose exec openvpn revoke-client alice # Revoke client
services:
openvpn:
# Pulls the published multi-arch image from GHCR - the five-minute path.
# Set CULVERT_IMAGE to pin a tag (e.g. ...culvert:v2.1.3) or point at a
# private mirror. To build locally instead, uncomment the build: block
# below and run `docker compose up --build`.
image: ${CULVERT_IMAGE:-ghcr.io/hyperi-io/culvert:latest}
# build:
# context: .
# dockerfile: Dockerfile
container_name: ${CONTAINER_NAME:-culvert}
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
# NET_ADMIN: network configuration, iptables, tun device. That is all the
# server needs - kernel modules (DCO, wireguard) are loaded on the HOST,
# so SYS_MODULE is not granted here. Uncomment it only if your host
# offers OpenVPN DCO kernel offload and you want the container to load it;
# it is close to host-root, since it can load any module.
cap_add:
- NET_ADMIN
# - SYS_MODULE
# Device access for VPN
devices:
- /dev/net/tun:/dev/net/tun
# The tunnels carry IPv4. IPv6 forwarding is deliberately NOT enabled:
# routing control (client isolation, the egress allow-list, the
# reverse-admin gate) is enforced with iptables only, so forwarded IPv6
# would walk straight past every one of those rules.
sysctls:
- net.ipv4.ip_forward=1
# DNS servers for container (required for OAuth2 OIDC discovery)
# Uses internal DNS first, falls back to Cloudflare
dns:
- ${CULVERT_DNS1:-1.0.0.1}
- ${CULVERT_DNS2:-1.1.1.1}
# Default publishes the simplest working server: OpenVPN UDP + the health
# endpoint. Every other listener is a deliberate opt-in - uncomment its
# port mapping AND set its CULVERT_*_ENABLED (or CULVERT_PROTOCOL) knob.
ports:
# UDP - Primary OpenVPN (best performance with DCO)
- "${CULVERT_UDP_PORT:-1194}:${CULVERT_UDP_PORT:-1194}/udp"
# Observability - health probes + Prometheus /metrics on one port
# (bind is CULVERT_METRICS_ADDR; keep the port here in sync with it).
# Published on loopback only: /livez + /metrics are unauthenticated.
# Scraping from another host? Change to "9090:9090/tcp" and firewall it.
- "127.0.0.1:9090:9090/tcp"
# TCP - Proxy fallback for corporate firewalls (CULVERT_TCP_ENABLED=true)
# - "${CULVERT_TCP_PORT:-1194}:${CULVERT_TCP_PORT:-1194}/tcp"
# HTTPS - OpenVPN inside TLS on 443 (CULVERT_HTTPS_ENABLED=true)
# - "${CULVERT_HTTPS_PORT:-443}:${CULVERT_HTTPS_PORT:-443}/tcp"
# WireGuard - UDP primary (CULVERT_PROTOCOL=wireguard or both)
# - "${CULVERT_WG_PORT:-51820}:${CULVERT_WG_PORT:-51820}/udp"
# WireGuard over HTTPS via wstunnel (CULVERT_WG_HTTPS_TUNNEL_ENABLED=true)
# - "${CULVERT_WG_HTTPS_TUNNEL_PORT:-4443}:${CULVERT_WG_HTTPS_TUNNEL_PORT:-4443}/tcp"
# OAuth2 callback servers, one per enabled OpenVPN listener
# (CULVERT_OAUTH2_ENABLED=true; each listener needs its own instance)
# - "${CULVERT_OAUTH2_UDP_PORT:-9000}:${CULVERT_OAUTH2_UDP_PORT:-9000}/tcp"
# - "${CULVERT_OAUTH2_HTTPS_PORT:-9001}:${CULVERT_OAUTH2_HTTPS_PORT:-9001}/tcp"
# - "${CULVERT_OAUTH2_TCP_PORT:-9002}:${CULVERT_OAUTH2_TCP_PORT:-9002}/tcp"
# Client config download server (CULVERT_CLIENT_DOWNLOAD_ENABLED=true)
# - "${CULVERT_CLIENT_DOWNLOAD_PORT:-8443}:${CULVERT_CLIENT_DOWNLOAD_PORT:-8443}/tcp"
volumes:
# PKI - certificates and keys (MANDATORY - persistent on host filesystem)
# These MUST be bind mounts to survive container recreation
- ${CULVERT_PKI_PATH:-./pki}:/etc/vpn/pki
# Client configs output (persistent on host filesystem)
- ${CULVERT_CLIENTS_PATH:-./clients}:/etc/vpn/clients
# Client-specific configs (ccd) - mount from host for customization
- ./config/ccd:/etc/vpn/server/ccd:ro
# Logs (persistent)
- ${CULVERT_LOGS_PATH:-./logs}:/var/log/vpn
# OAuth2 TLS certificates (required when CULVERT_OAUTH2_ENABLED=true)
# Mount wildcard or server-specific certificate for HTTPS callback endpoint
- ${OAUTH2_TLS_CERT_PATH:-/opt/openvpn/certs}:/etc/vpn/oauth2-tls:ro
environment:
# Server identity - MUST match DNS/certificate
- CULVERT_SERVER_CN=${CULVERT_SERVER_CN:-vpn.example.com}
# Listener configuration. Default is the simplest working server:
# OpenVPN UDP only. The TCP and HTTPS listeners are deliberate opt-ins.
# UDP - Primary (best performance)
- CULVERT_UDP_ENABLED=${CULVERT_UDP_ENABLED:-true}
- CULVERT_UDP_PORT=${CULVERT_UDP_PORT:-1194}
- CULVERT_UDP_NETWORK=${CULVERT_UDP_NETWORK:-10.8.0.0}
- CULVERT_UDP_NETMASK=${CULVERT_UDP_NETMASK:-255.255.255.0}
# HTTPS - OpenVPN wrapped in TLS on port 443 (opt-in)
- CULVERT_HTTPS_ENABLED=${CULVERT_HTTPS_ENABLED:-false}
- CULVERT_HTTPS_PORT=${CULVERT_HTTPS_PORT:-443}
- CULVERT_HTTPS_NETWORK=${CULVERT_HTTPS_NETWORK:-10.8.2.0}
- CULVERT_HTTPS_NETMASK=${CULVERT_HTTPS_NETMASK:-255.255.255.0}
# TCP - Proxy fallback (opt-in)
- CULVERT_TCP_ENABLED=${CULVERT_TCP_ENABLED:-false}
- CULVERT_TCP_PORT=${CULVERT_TCP_PORT:-1194}
- CULVERT_TCP_NETWORK=${CULVERT_TCP_NETWORK:-10.8.1.0}
- CULVERT_TCP_NETMASK=${CULVERT_TCP_NETMASK:-255.255.255.0}
# DNS servers pushed to clients
- CULVERT_DNS1=${CULVERT_DNS1:-1.1.1.1}
- CULVERT_DNS2=${CULVERT_DNS2:-1.0.0.1}
# Routing
- CULVERT_FULL_TUNNEL=${CULVERT_FULL_TUNNEL:-false}
- CULVERT_PUSH_ROUTES
- CULVERT_DNS_DOMAIN
# Routing control: who clients may reach, and who may reach back down the
# tunnels. Off by default (clients route wherever the host can). Turning it
# on denies client-to-client and unsolicited inbound; see .env.example.
- CULVERT_ROUTING_CONTROL_ENABLED=${CULVERT_ROUTING_CONTROL_ENABLED:-false}
- CULVERT_CLIENT_ISOLATION=${CULVERT_CLIENT_ISOLATION:-true}
- CULVERT_ALLOWED_DESTINATIONS
- CULVERT_DOWNSTREAM_ADMIN_CIDRS
# Clients cannot reach 169.254.0.0/16, which on a cloud instance is the
# metadata service and this host's credentials. On by default, and applies
# whether or not the routing control above is enabled.
- CULVERT_BLOCK_LINK_LOCAL=${CULVERT_BLOCK_LINK_LOCAL:-true}
# Site profile: loads profiles/<name>.yaml as a base for everything above
- CULVERT_PROFILE
# WireGuard (opt-in: set CULVERT_PROTOCOL=wireguard, or both to run it
# alongside OpenVPN, then uncomment the knobs you need)
# - CULVERT_PROTOCOL=${CULVERT_PROTOCOL:-openvpn}
# - CULVERT_WG_NETWORK=${CULVERT_WG_NETWORK:-10.8.3.0/24}
# - CULVERT_WG_PORT=${CULVERT_WG_PORT:-51820}
# - CULVERT_WG_MTU=${CULVERT_WG_MTU:-1420}
# - CULVERT_WG_PERSISTENT_KEEPALIVE=${CULVERT_WG_PERSISTENT_KEEPALIVE:-25}
# WireGuard over HTTPS via wstunnel (a further opt-in on top of WireGuard)
# - CULVERT_WG_HTTPS_TUNNEL_ENABLED=${CULVERT_WG_HTTPS_TUNNEL_ENABLED:-false}
# - CULVERT_WG_HTTPS_TUNNEL_PORT=${CULVERT_WG_HTTPS_TUNNEL_PORT:-4443}
# Network profile: "default" (fast internet) or "wireless" (4G/mobile optimized)
- CULVERT_NETWORK_PROFILE=${CULVERT_NETWORK_PROFILE:-default}
# Performance tuning (optional - profile sets sensible defaults)
- CULVERT_SNDBUF
- CULVERT_RCVBUF
- CULVERT_TUN_MTU
- CULVERT_MSSFIX
- CULVERT_KEEPALIVE_PING
- CULVERT_KEEPALIVE_TIMEOUT
- CULVERT_MAX_CLIENTS=${CULVERT_MAX_CLIENTS:-auto}
- CULVERT_RENEG_SEC
# Concurrent connections per client identity (laptop + phone). Set
# ALLOW_SHARED_CLIENTS=false to make each credential exclusive.
- CULVERT_ALLOW_SHARED_CLIENTS=${CULVERT_ALLOW_SHARED_CLIENTS:-true}
- CULVERT_SHARED_CLIENT_SLOTS=${CULVERT_SHARED_CLIENT_SLOTS:-2}
- CULVERT_LOG_MODE=${CULVERT_LOG_MODE:-file}
- CULVERT_VERB
- CULVERT_MUTE
# Observability listener: always serves health probes; /metrics is
# served when CULVERT_METRICS_ENABLED=true
- CULVERT_METRICS_ENABLED=${CULVERT_METRICS_ENABLED:-false}
- CULVERT_METRICS_ADDR=${CULVERT_METRICS_ADDR:-0.0.0.0:9090}
# Client config download server (REQUIRES a bearer token when enabled:
# the served configs embed client private keys, so it fails closed)
- CULVERT_CLIENT_DOWNLOAD_ENABLED=${CULVERT_CLIENT_DOWNLOAD_ENABLED:-false}
- CULVERT_CLIENT_DOWNLOAD_PORT=${CULVERT_CLIENT_DOWNLOAD_PORT:-8443}
- CULVERT_CLIENT_DOWNLOAD_TOKEN
# PKI settings (CNSA 2.0 compliant defaults)
# ORG_NAME names the CA: "Acme" -> "Acme VPN CA". CA_CN overrides that
# derivation outright, so it is passed through empty rather than
# defaulted - set it only if you want to name the CA yourself.
- CULVERT_ORG_NAME
- CULVERT_CA_CN
- CULVERT_KEY_TYPE=${CULVERT_KEY_TYPE:-ec}
- CULVERT_KEY_SIZE=${CULVERT_KEY_SIZE:-secp384r1}
# OpenVPN data-channel cipher order. AES-256-GCM first is the CNSA/FIPS
# default; a profile (or this var) may prefer ChaCha20 -- which leaves CNSA.
- CULVERT_DATA_CIPHERS=${CULVERT_DATA_CIPHERS:-AES-256-GCM:CHACHA20-POLY1305}
# Local-PKI certificate lifetimes (days). Defaults: 10-year CA, 2-year
# leaf. Raise for a set-and-forget deployment with no rotation operator,
# e.g. 7300 (20y), so the server cert cannot silently expire.
- CULVERT_CA_EXPIRE_DAYS=${CULVERT_CA_EXPIRE_DAYS:-3650}
- CULVERT_CERT_EXPIRE_DAYS=${CULVERT_CERT_EXPIRE_DAYS:-730}
- CULVERT_CRL_DAYS=${CULVERT_CRL_DAYS:-180}
# "local" mints a CA on first start. "external" takes the CA and server
# keypair from a secrets backend instead, which needs every
# CULVERT_SECRETS_* path below - see the External PKI section of
# .env.example. TC_KEY_PATH is what lets more than one server share
# clients; a single server can leave it unset and mint its own.
- CULVERT_PKI_MODE=${CULVERT_PKI_MODE:-local}
- CULVERT_SECRETS_PROVIDER
- CULVERT_SECRETS_CA_CERT_PATH
- CULVERT_SECRETS_SERVER_CERT_PATH
- CULVERT_SECRETS_SERVER_KEY_PATH
- CULVERT_SECRETS_CRL_PATH
- CULVERT_SECRETS_TC_KEY_PATH
# OIDC SSO, opt-in (works with Entra ID, Okta, Keycloak, Google, Auth0, etc.)
- CULVERT_OAUTH2_ENABLED=${CULVERT_OAUTH2_ENABLED:-false}
- CULVERT_OAUTH2_ISSUER
- CULVERT_OAUTH2_CLIENT_ID
- CULVERT_OAUTH2_CLIENT_SECRET
- CULVERT_OAUTH2_VALIDATE_GROUPS
# Per-listener OAuth2 enable/disable (defaults to CULVERT_OAUTH2_ENABLED)
- CULVERT_OAUTH2_UDP_ENABLED
- CULVERT_OAUTH2_HTTPS_ENABLED
- CULVERT_OAUTH2_TCP_ENABLED
# Per-listener OAuth2 callback ports (defaults: 9000=UDP, 9001=HTTPS, 9002=TCP)
- CULVERT_OAUTH2_UDP_PORT=${CULVERT_OAUTH2_UDP_PORT:-9000}
- CULVERT_OAUTH2_HTTPS_PORT=${CULVERT_OAUTH2_HTTPS_PORT:-9001}
- CULVERT_OAUTH2_TCP_PORT=${CULVERT_OAUTH2_TCP_PORT:-9002}
- CULVERT_OAUTH2_HTTP_SECRET
# Default scopes match the code default. Add offline_access for IdPs
# that issue refresh tokens through it (e.g. Entra ID); some IdPs
# reject unknown scopes, so it is not the default.
- CULVERT_OAUTH2_SCOPES=${CULVERT_OAUTH2_SCOPES:-openid,profile,email}
# OAuth2 TLS (REQUIRED when CULVERT_OAUTH2_ENABLED=true - most IdPs
# require HTTPS redirect URIs). Paths inside the container; mount certs
# via the OAUTH2_TLS_CERT_PATH volume above.
- CULVERT_OAUTH2_TLS_CERT=${CULVERT_OAUTH2_TLS_CERT:-/etc/vpn/oauth2-tls/fullchain.pem}
- CULVERT_OAUTH2_TLS_KEY=${CULVERT_OAUTH2_TLS_KEY:-/etc/vpn/oauth2-tls/privkey.pem}
# stunnel TLS (for the opt-in HTTPS tunnel - same certs as OAuth2 by default)
# Note: Use fullchain (leaf + intermediate CA) for proper certificate verification
- CULVERT_STUNNEL_CERT=${CULVERT_STUNNEL_CERT:-/etc/vpn/oauth2-tls/fullchain.pem}
- CULVERT_STUNNEL_KEY=${CULVERT_STUNNEL_KEY:-/etc/vpn/oauth2-tls/privkey.pem}
# OAuth2 Branding (optional)
# Custom HTML template and assets for OAuth login page. The assets
# fallback repeats the code default because an empty-but-set env var
# would override it with "" and drop the baked-in branding.
- CULVERT_OAUTH2_TEMPLATE
- CULVERT_OAUTH2_ASSETS_PATH=${CULVERT_OAUTH2_ASSETS_PATH:-/etc/openvpn-auth-oauth2/assets}
# Timezone
- TZ=${TZ:-Australia/Sydney}
# No healthcheck override: the image's protocol-aware HEALTHCHECK
# (entrypoint healthcheck -> /livez) covers OpenVPN, WireGuard, and
# both. A pgrep for openvpn would report a WireGuard-only server
# unhealthy forever.
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
#=============================================================================
# Watchtower - Automatic Docker Container Updates
#=============================================================================
# Monitors containers and updates them when new images are available.
# Schedule: Daily at 04:00 (after 03:00 VM security updates)
# Only updates containers with com.centurylinklabs.watchtower.enable=true label
#
# Behavior:
# - Pulls new image if available
# - Stops container gracefully
# - Recreates with same config
# - Removes old image
#
# Disable: Set WATCHTOWER_ENABLED=false in .env
watchtower:
# Note: containrrr/watchtower was archived Dec 2025 and broken with Docker 29+
# Using maintained fork (pinned): https://github.com/nickfedor/watchtower
# SECURITY: mounting docker.sock hands this container root-equivalent
# control of the HOST - keep this profile opt-in and the tag pinned.
image: nickfedor/watchtower:1.20.3
container_name: watchtower
restart: unless-stopped
profiles:
- watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
# Schedule: 04:00 daily (cron format: sec min hour day month weekday)
- WATCHTOWER_SCHEDULE=0 0 4 * * *
# Only update containers with enable label (opt-in)
- WATCHTOWER_LABEL_ENABLE=true
# Remove old images after update
- WATCHTOWER_CLEANUP=true
# Timezone
- TZ=${TZ:-Australia/Sydney}
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
# NOTE: PKI, clients, and logs now use bind mounts (host directories) by default
# This ensures persistence across container recreation and volume removal
# Set CULVERT_PKI_PATH, CULVERT_CLIENTS_PATH, CULVERT_LOGS_PATH in .env to customize paths
#
# Watchtower: Enable with `docker compose --profile watchtower up -d`
# Or add COMPOSE_PROFILES=watchtower to .env for always-on