# vpn: a VPN I only pay for while I’m using it

> A service I use is geo-restricted, so I wrote one command that runs my own VPN on EC2 and stops the instance, and the bill, as soon as I’m done.

Javed Habib · Sep 3, 2024 · #aws #bash #vpn #weekend-project  
https://jaeaeich.com/writing/vpn

I needed a VPN for the most boring reason there is: a service I use is
geo-restricted to a region I don't live in, and its official advice is "use a
VPN". I went through 3 or 4 commercial VPNs over a couple of months, hit
the same small annoyances every time, mostly subscriptions and capped bandwidth
with a side of suspiciously cheerful privacy promises, and eventually thought:
*fine, I'll do it myself.*

The plan was to rent the cheapest [EC2](https://aws.amazon.com/ec2/) instance
AWS offers, put [OpenVPN](https://openvpn.net/access-server/) on it and connect
my laptop. It worked, right up to the point where I noticed an AWS bill that had
**grown while I slept**, because an EC2 instance doesn't stop running just
because I stopped using the VPN.

The fix ended up being a 140-line bash script called
[`vpn`](https://github.com/jaeaeich/vpn), which is what the rest of this post is
about.

## The problem

The whole bug was that I kept leaving the instance running. The textbook answer
is to stop the instance when you're done and start it again when you need it,
which amounts to 2 AWS CLI commands and a vague intention to remember them,
and I have a poor track record with vague intentions. The answer that *actually*
works is to make starting and stopping the instance **part of using the VPN**,
so that there is nothing separate to remember.

That meant `vpn` had to be a single command with the instance off by default:
run it once and it starts the instance, waits for it and connects; run it again
and it shuts the instance down. If I never type `vpn`, the instance costs me
**nothing but its disk**.

## What the script does

It's one bash file that has kept the same shape since the first afternoon. When
you run it, it asks AWS for the instance tagged `Name=openvpn`, reads its state
and branches:

- **Stopped:** start it, poll every 10 seconds until AWS says it's running,
  then connect.
- **Running:** print `Did you forget to stop EC2? ooh the bill!!!` and shut it
  down.

start instance → wait → swap IP → connect

*Figure: What vpn does when it finds the instance stopped; run it again and it stops the instance instead.*

That second message prints every time I stop the VPN, including the times I
*didn't* forget, and I have never changed it, so it's still sitting
on [jaeaeich/vpn/vpn.sh:79](https://github.com/jaeaeich/vpn/blob/main/vpn.sh#L79).

The part that took longest to get right was the IP address, because EC2 gives an
instance a new public IP every time it starts unless you pay for an
Elastic IP, which I refuse to do for a personal VPN. The OpenVPN client
profile (`client.ovpn`) has the server's address written into it as a plain
line, `remote 1.2.3.4 1194 udp`, so if you start the instance with a stale line,
the client sits there **timing out against yesterday's IP**.

Once the instance is up, then, the script:

- asks AWS for the new IP
- finds the old one in the profile
- swaps them
- hands the profile to [`openvpn3`](https://github.com/OpenVPN/openvpn3-linux)

These few lines are most of what the script exists to do:

```bash title="vpn.sh"
INSTANCE_PUBLIC_IP=$(aws ec2 describe-instances \
  --region "$REGION" \
  --instance-ids "$INSTANCE_ID" \
  --query "Reservations[].Instances[].PublicIpAddress" \
  --output text)

PREV_IP=$(grep remote "$PATH_TO_OVPN_CLIENT_CONFIG" | head -n 1 | awk '{print $2}')
sed -i "s/$PREV_IP/$INSTANCE_PUBLIC_IP/g" "$PATH_TO_OVPN_CLIENT_CONFIG"

printf "$USERNAME\n$PASSWORD\n" | openvpn3 session-start --config "$PATH_TO_OVPN_CLIENT_CONFIG"
```

A day with it looks roughly like this:

```console title="a day with vpn"
$ vpn
Waiting for the instance to start...
Current instance state: pending
Current instance state: running
Public IP Address: 13.250.xx.xx

(openvpn3 connects, and I do whatever I needed the VPN for)

$ vpn
Did you forget to stop EC2? ooh the bill!!!
Shutting down OpenVPN server...
Waiting for the instance to stop...
Current instance state: stopped
The instance has been successfully stopped.
```

Because stopping is the same command as starting, **I can't forget it**, and AWS
no longer gets paid to babysit an idle `t2.micro`.

## Config in a `.vpnrc`

The first version had my region, instance name, and OpenVPN username and
password hardcoded, which was fine while I was the only user but became a
problem once I wanted to put it on GitHub without also committing my password.

The fix is the one Unix tools have used for decades, an rc file, and the script
looks for it in 2 places, in order:

1. `$HOME/.config/vpn/.vpnrc`
2. `$HOME/.vpnrc`

If it finds one it sources it, and if not it says so and falls back to the
defaults baked into the script. Because the `.vpnrc` is just a shell file, you
can `chmod 600` it, keep it in your dotfiles and edit it with whatever editor is
already open, without any new config format or parser to go with it.

```sh title="~/.config/vpn/.vpnrc"
INSTANCE_NAME="openvpn"
USERNAME="openvpn"
PASSWORD="your_password"
REGION="ap-southeast-1"
PROFILE_NAME="client.ovpn"
```

That file is what makes the script shareable: clone the repo, drop your `.vpnrc`
into `~/.config/vpn/`, symlink the script onto your `$PATH`, and you're done.

## Choices I'd keep, and one I wouldn't

**AWS CLI instead of an SDK.** You don't need an SDK or Terraform for something
like this, because
`aws ec2 describe-instances --filters "Name=tag:Name,Values=$INSTANCE_NAME"` is
already a shell command that returns JSON, and `--query`
([JMESPath](https://jmespath.org), built into the CLI) pulls out exactly the
field you want.

**Strict mode.** I should have used `set -euo pipefail` and didn't, so instead
the script grew a set of hand-written checks: a helper that bails if a value is
empty or `None`, a check that the profile file exists, and so on. That is
backwards, since strict mode does most of that work by itself and the
hand-rolled version only makes the script longer. If I rewrote it today, strict
mode would go at the top and half the checks would go away.

**The fallback password.** The one in the script is literally `my_password`, and
that is deliberate: if you forget to set up your `.vpnrc`, OpenVPN rejects the
login and you find out straight away instead of an hour later, wondering why
nothing connects.

## Setup, limits and cost

The script only starts and stops a VPN you've already set up, and the setup is a
one-time job: launch the [OpenVPN Access
Server](https://openvpn.net/access-server/) image from the AWS Marketplace, tag
the instance `openvpn`, download the client profile and put it next to the
script, and from then on `vpn` is your front door.

It is also deliberately small, one user and one instance with nothing running in
the background to reconnect you on flaky wifi, so if you need more than that
you'll want a different tool.

It isn't free either, since AWS bills for:

- instance hours
- the public IP while the instance is up
- data going out
- the instance's disk

On the [free tier](https://aws.amazon.com/free/), with a `t2.micro` and light
use, it costs close to nothing, and off the free tier it's
**a few dollars a month**, which is still less than most commercial VPNs and
comes with whatever bandwidth your region gives you.

## Why it stayed small

I still like it mostly because it stayed at **140 lines of bash**, which I'd
have happily buried under Terraform and a config format if I'd started with a
bigger plan. It needs nothing beyond the AWS CLI and `openvpn3`, so it should
keep working for as long as AWS keeps `start-instances` around.

The code is at [jaeaeich/vpn](https://github.com/jaeaeich/vpn) under GPL-3.0. If you use it,
double-check which AWS region you launch the instance in, since that's where
your traffic will look like it's coming from.
