SealSkin

VPN profiles

Route sessions through a provider's WireGuard or OpenVPN servers without changing the application images.

VPN profiles

A VPN profile is a provider's WireGuard or OpenVPN configuration as SealSkin keeps it: one template, the credentials it asks for, and a list of endpoints, one per server. A user who picks a profile at launch gets a session whose traffic leaves through that provider, and the session's image knows nothing about it: no VPN client is installed in the application image, no capability is granted to the session container, and nothing in the home directory changes.

All of it is optional. Nothing runs and nothing is pulled until an administrator imports a profile and a user picks it, and a deployment that never does needs none of what this page describes: no gateway image, no settings, and on Kubernetes none of the Role rules the hubs take. A node that cannot run a gateway refuses the launch with the reason and runs every other session as before.

How a session is routed

On a node running Docker, a session that chose a profile and an endpoint is started on an internal Docker network of its own. An internal network has no route out, so the session reaches nothing on its own. Beside it, the node runs a gateway container from the image SEALSKIN_VPN_IMAGE (ghcr.io/selkies-project/sealskin-vpn) that holds the one tunnel to the endpoint, forwards the sessions' traffic into the tunnel and nowhere else, and answers their DNS through it. The session's /etc/resolv.conf names the gateway, and a one-shot helper from the same image, run in the session's network namespace right after it starts, points the session's default route at the gateway. The server's own container joins the network to reach the session as it reaches any other.

The gateway's firewall allows forwarding only from the sessions into the tunnel interface, and its own output only to the endpoint it was given. A VPN session on Docker reaches nothing but the gateway: not the host, not its LAN, not another container. A tunnel that drops leaves the sessions with no path out rather than with the node's connection, and a gateway container that exits or turns unhealthy is started again in place by the node for the sessions still using it, never rebuilt under them.

One gateway per profile and endpoint on a node carries every session that picked them, so a provider's device limit is spent once per endpoint in use, however many sessions share it. The gateway starts with the first session that needs it and stops shortly after the last one ends. Its rendered files live under SEALSKIN_NODE_STATE_PATH/vpn/<key> while it runs, and its container is sealskin-vpn-<key>, which is where to look with docker logs when a launch reports that the VPN did not connect.

On Kubernetes

Pods have no internal network to share, and a session pod may land on any node, so on Kubernetes the gateway is a hub: a pod per profile and endpoint, owned by the server like a session pod, holding the one tunnel to the provider and a WireGuard interface of its own behind a ClusterIP Service. A session pod gets a native sidecar from the same image that connects to the hub over the pod network with a key pair of its own, makes that the pod's default route before the application starts, keeps the cluster's networks on the pod's own route, and lets nothing else out; the pod resolves names at the hub. One hub carries every session of the deployment that picked its endpoint, wherever the pods run, so the provider's connection is spent once per endpoint in use, and the hub goes away with the last session using it.

The hub's profile is a Secret the pod mounts, which the server also updates with the spokes, and the server adds each session's spoke to the running hub through pods/exec. The Role in kubernetes/sealskin.yml grants the Secrets, Services, and exec this takes; a deployment that will not use profiles can leave those rules out, and a launch with a profile is then refused with the API server's answer. The hub needs NET_ADMIN, /dev/net/tun from the node, and IP forwarding, which a pod inherits from its node; a cluster that turns forwarding off in pods needs the net.ipv4.ip_forward sysctl allowed. The session pod's sidecar needs NET_ADMIN and nothing else; the application container is unchanged. SEALSKIN_VPN_HUB_SUBNET gives the hubs and spokes their addresses, and SEALSKIN_VPN_LOCAL_CIDRS names the networks that stay direct beside the tunnel: the private ranges by default, which cover a cluster's pod and Service ranges. Unlike a session on Docker, a VPN session on Kubernetes still reaches everything in those ranges, the cluster's LAN included; narrow the setting to the pod and Service ranges where that is not wanted. The hub pod is sealskin-vpn-<instance>-<key>, named after the server's app.kubernetes.io/instance so that two servers in one namespace keep hubs of their own, and its log is where to look when a launch reports that the VPN did not connect. A hub pod that fails or is evicted is created again over its Secret, with the keys it had, so the sessions' spokes reconnect to it.

Importing a profile

VPN Profiles in the dashboard imports a profile from one .ovpn or .conf file, or from a zip of them as the providers hand them out. The importer strips what names a server from every file and keeps the rest as the template; files that share a template become one profile with an endpoint per server, and a file whose template differs becomes a profile of its own, named after the upload and what sets it apart.

For OpenVPN, the server lines (remote, proto, port, remote-random) become the endpoints, the certificates stay inline in the template, and a config that refers to separate ca, cert, key, or tls-auth files has them inlined from the same zip. A config with auth-user-pass asks for the provider's service credentials, which the import form takes; these are not the account's password at most providers. Script directives (up, down, route-up, plugin, tls-verify, and the like) are dropped with a line in the log, since the gateway runs as root and runs no program a config names. For WireGuard, the [Interface] section is the template and each [Peer] an endpoint, with its preshared key when it has one.

What the big providers hand out, as of this writing:

ProviderOpenVPNWireGuard
NordVPNPublic zip of every server in UDP and TCP folders; service credentialsNo manual config (the key is issued by their API)
SurfsharkPublic zip; service credentialsOne file per location from the account page
Private Internet AccessPublic zips in several cipher and port variants; account credentialsNo static config
IVPNPublic zip; the account id as usernameRegister a key, then one file per server
Proton VPNPer-file downloads from the account page; OpenVPN credentialsOne file per server, each with a key and an expiry of its own
MullvadDropped in January 2026Zip of every relay sharing one interface
Windscribe, AirVPN, TorGuardConfig generatorsConfig generators
ExpressVPN, CyberGhostPer-location downloads (CyberGhost ships separate certificate files)None

A profile keeps its template, the OpenVPN username and password, and the WireGuard preshared keys as its secrets. On a cluster with SEALSKIN_STORE_KEY set they are sealed under the cluster key before the record leaves the node, the way the users' shared files are (Object store); without one they are kept as the users' files are. The dashboard never shows them again; an edit can replace the credentials.

Publishing and editing endpoints

A freshly imported profile offers its users the first endpoint alone. In the profile's editor, tick the endpoints users may pick, in the order to offer them: the launcher then shows an endpoint field after the profile, with the first ticked endpoint as the default. A provider's zip of thousands of servers is best published as a handful of locations; the search box finds them by label or host.

Every endpoint is editable in place: its label, host, port, and for OpenVPN the protocol, for WireGuard the server's public key and preshared key, which the editor never shows again once saved. An endpoint can be removed, added by hand with Add endpoint, or added from another config file or archive with Add from file, whose endpoints are appended to the profile under the profile's template. A pasted config with several remote lines or several [Peer] sections imports as several endpoints the same way. A gateway already up for an endpoint keeps the settings it started with until it stops.

Connection cap limits how many gateways the whole deployment runs for the profile at once, which is how many of the provider's connections it spends: every node counts its own gateways and the sessions the other nodes last reported, and a launch past the cap is refused with the count, so the user can pick an endpoint already in use. Users and Groups say who may pick the profile; Everyone unless names are chosen. The Allow VPN Profiles switch in a user's or group's settings turns the choice off for an account altogether. The profiles table shows every connection up across the deployment with its node and the sessions on it.

Settings

SettingMeaning
SEALSKIN_VPN_IMAGEThe gateway image, and the helper that sets a session's route. A node pulls it on first use; pin it to the server's version (sealskin-vpn:<version>) rather than latest, so that a pull on a new node brings the image the server was tested with.
SEALSKIN_VPN_SUBNETSThe range the gateways' internal networks are carved from, one /24 each (10.230.0.0/16). Change it when it collides with a network the host reaches.
SEALSKIN_VPN_HUB_SUBNETOn Kubernetes, the network a hub and its spokes take their addresses from (10.231.0.0/16), the hub at .1.
SEALSKIN_VPN_LOCAL_CIDRSOn Kubernetes, the networks a session pod and a hub keep reaching directly beside the tunnel: the private ranges by default.
SEALSKIN_VPN_PROFILES_PATHThe shared record of the profiles.

The gateway image is built from vpn/ in the repository and published to the GitHub Container Registry from main, tagged with the version and latest; sealskin-vpn:dev built from the checkout serves a test deployment, with SEALSKIN_VPN_IMAGE naming it.