Ben Lobaugh Online

I think, therefore I am. I am, therefore I sail

Build Highly Performant WordPress Sites with Minio and WP Offload Media

WordPress is the leading content management system and it is often thought that WordPress cannot scale. That assertion is a bit misleading. It is true that out of the box WordPress does not scale well, however, WordPress has a flexible hooks system that provides developers with the ability to tap into and alter many of its features. With a little effort, WordPress can indeed be made to be highly performant and infinitely scalable.

One of the first steps that must be taken when building any highly performant and scalable website is to offload the uploaded media files from the web servers. Amazon’s S3 is the top contender in this area, but there are many reasons why it may be desirable to host the files on your own server. Minio is an open source project that allows you to run and host your own S3 compliant service. Minio can be run as a standalone or fully distributed service and has been used to power sites with many terabytes of media.

In this article I am going to show you how I was able to leverage the WordPress WP Offload Media plugin to host uploaded media files on a Minio service.

Both WordPress and Minio have well written installation guides. You will need both set up if you are following along:

There are two versions of WP Offload Media:

The Lite version is free and provides everything you need to host new images on Minio. The Pro version has some nice additional features, such as the ability to migrate existing files in the Media Library. Both versions are excellent. Which you need will be dependent upon your use case.

For the rest of this article, I will be using on of my own websites for demonstration purposes. The site is live and available at Raising fish has been a hobby of mine since I was a child. This is a site I have been working on that will allow anyone to share my passion, and get a glimpse into my tanks.

At the this articles was published, the site architecture was:

The site already existed when I decided to add support for Minio. Because there were existing media files that needed to be offloaded, I chose to go with WP Offload Media Pro version, for its ability to offload existing content. To save disk space on the web server, I also opted to remove all uploaded files from the web server as soon as they transferred to the Minio server.

After the initial installation, WP Offload Media will present you with a storage provider page where you can fill in an Amazon S3 access key and secret.

Because I am hosting on Minio, I am going to ignore the storage provider form completely and manually add the keys to the wp-config.php file. Even if you are using S3, I recommend putting the configuration into the wp-config.php file. Using the database option will cause delays as the site queries the database, and have a negative performance impact, especially on high traffic sites.

Place the following in the wp-config.php file:

define( 'AS3CF_SETTINGS', serialize( array(
'provider' => 'aws',
'access-key-id' => 'minio-key',
'secret-access-key' => 'minio-secret',
) ) );

Notice the provider key says aws and there is no mention of our Minio server? Out of the box, WP Offload Media does not have an option for Minio, however, because Minio is fully S3 compatible, we can alter the URL for the provider away from S3 and to our own services with the following code:

function minio_s3_client_args( $args ) {
$args['endpoint'] = '';
$args['use_path_style_endpoint'] = true;return $args;
}add_filter( 'as3cf_aws_s3_client_args', 'minio_s3_client_args' );

I put that code into an mu-plugin, to ensure it runs all the time and cannot easily be disabled by plugin deactivation or theme changes.

The URL is not a web accessible domain. It is how the web server Docker container communicates with the other container that is running Minio*. In your use case, it may be a web accessible domain that is pointed to.

Media files are accessible at This is managed by another small snippet of code that creates the URL string for the media file:

add_filter( 'as3cf_aws_s3_url_domain', 'minio_s3_url_domain' , 10, 2 );function minio_s3_url_domain( $domain, $bucket ) {
return '' . $bucket;

Back on the storage provider page, pick the bucket you would like to save the images in. Then update the rest of the settings to your liking.

My config is:

  • Provider: Amazon S3
  • Bucket: media
  • Copy files to Bucket: true
  • Path: off
  • Year/Month: off
  • Object Versioning: off
  • Rewrite Media URLs: on
  • Force HTTPS: off (The webserver handles this for use)
  • Remove Files from Server: Yes

I then clicked the offload existing media button and began to see files appear on the Minio server immediately.

Load the site and validate the media URLs are pointing to the Minio URL.

That is all it took to offload the WordPress Media Library to Minio!

Offloading the Media Library is one of the components required to build a highly performant and scalable WordPress site. This will allow you to increase the number of web server instances near limitlessly, without worrying about file system replication or syncing issues. You have now seen how easily this can be accomplished with Minio. I challenge you to go forth and conquer Minio on your own WordPress powered site!

How to Use the Maxmind Javascript API to Control Content by City, State, or Country

Controlling what a website visitor sees, based on their geo location, is a fairly common activity today. Most often, this happens on the server, before the content is generated for the visitor to see, but what if you do not have access to manipulate server side code and can only update the javascript that the site is using? Using the MaxMind Javascript API makes this much easier than you might think.

In this article I am going to show you how I accomplished this, in a way that you can easily replicate. What I am not going to do is show you how to build a website or teach Javascript principles.

Let’s consider this scenario:

  • HTML has already been generated by the server
  • There is no access to change the server side code
  • We do have that ability to add Javascript to the site
  • All elements that must be hidden have the class attribute of “geo-hide”
  • Visitors from Washington State, USA must not see the specified content


  • Working website with edit access for javascript
  • MaxMind account. The free trial will suffice

We will be using the MaxMind GeoIP2 Javascript Client API and the GeoIP2 Precision Service.

Architecting the solution

  • When a page loads, determine what state the visitor is making the request from
  • If the state is not Washington, allow them to see all the content
  • If the state is Washington, remove all HTML elements with the class attribute of “geo-hide” from the DOM

Note: This solution does require javascript be enabled on the visitor’s browser. It is rare that javascript is disabled these days. If you are concerned, look up any of the methods of requiring javascript to be enabled in a browser.

Another Note: This solution does not provide any protection against bots. MaxMind charges per query to their API- to save money, be sure you only query Maxmind on legitimate visits.

Final Note: This solution will query MaxMind on every page load. It is advised to use some caching method to prevent unnecessary calls. For example, you could cache the geolocation response as a session cookie. If the cookie is set, do not call Maxmind, if not, call Maxmind.

Include the GeoIP2 Javascript client library

MaxMind has already built a Javascript library that contains the functionality we need. All that needs to be done is to include it.

Add the following script include:

<script src=”//” type=”text/javascript”></script>

Set up the content control code

The Javascript API has a single object that lets us easily retrieve the visitor location. It is onSuccess, onError );

The two parameters are callbacks, the first contains the geolocation data, the second contains an error, in the event that a geolocation could not be determined.

Plug that into a simple object and we have the following.

var geoipcheck = (function () {
var onSuccess = function (geoipResponse) {
var state = geoipResponse.subdivisions[0].iso_code; if ( WA == state ) {
var elements = document.getElementsByClassName(‘geo-hide’);
while(elements.length > 0){

}; var onError = function (error) {
// Error control code here
}; return function () { onSuccess, onError );

As you can see in the onSuccess function, if the visitor’s state is listed as WA, or Washington, the HTML elements with the class of “geo-hide” will be removed from the DOM.

This is by no means a foolproof, or complete, solution, but it will get you up and running with the ability to control content via geolocation. This is particularly useful on services such as Shopify which do not allow you to alter what is rendered on the server side of things.


JWT User Authentication API with Lumen

Lumen is a great framework to build an API off of, but it does not come with user authentication or authorization. I needed to create a small API that allowed users to create an account and access the service with a JWT. Quality information on how to pull that off with Lumen is not very well available- this article will provide a single reference point on building a simple user authentication and authorization system with JWTs on Lumen.

In this article I will teach you how to set up user authentication and authorization in Lumen. I will not teach you what Lumen or JWT is. I am going to assume you know what a they are or you would not be reading this article.

If you are following along, the prerequisite for what follows is:

  • Running Lumen project

To see the complete code from this article, visit

This example includes a docker-compose.yml file that will get you up and running quickly.

For the JWT portion, we will be utilizing the excellent library from

Install the JWT Library

We will be utilizing the JWT library by Sean Tymon. The library is installable as a composer package, and can be installed with the following command:

composer require tymon/jwt-auth

A secret needs to be generated to configure the JWT library, and added to the .env file. It can be generated with the following artisan command.

php artisan jwt:secret

The .env file was automatically updated with the key. The key will be used to sign all the JWT tokens.

Prep Lumen

There are now some steps we need to take to prep Lumen, before we can implement the user authentication portion.

To begin, open up the file bootstrap/app.php, then add or uncomment the following:

'auth' => App\Http\Middleware\Authenticate::class,

Add the JWT Service Provider


Set up the Auth Config

This part bit me at first- Lumen does not come with the config directory like Laravel does. You will need to create it and a file called auth.php.

Create the file config/auth.php and add the following:

<?phpreturn [
'defaults' => [
'guard' => 'api',
'passwords' => 'users',
],'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => \App\Models\User::class

Create the user table

Lumen does not come with the user tables out of the box, so we will need to create them ourselves. We are going to create the same tables that Laravel uses.

Run the following two artisan commands to generate the migration files:

php artisan make:migration create_users_tablephp artisan make:migration create_password_resets_table

Place the following code in the up() method of the create_users_table migration:

Schema::create('users', function (Blueprint $table) {  $table->increments('id');  $table->string('name');  $table->string('email')->unique();  $table->timestamp('email_verified_at')->nullable();  $table->string('password');  $table->rememberToken();  $table->timestamps();});

Place the following code in the up() method of the create_password_resets_table migration:

Schema::create(‘password_resets’, function (Blueprint $table) {

Schema::create('password_resets', function (Blueprint $table) {  $table->string('email')->index();  $table->string('token');  $table->timestamp('created_at')->nullable();});

Finally, run the migrate command to create the tables!

php artisan migrate

Set up the User Model

The User model will allow us to create a representation of the user that can manipulate the database table, and manage a user’s JWT tokens.

Open your User model and add the following use statement:

use Tymon\JWTAuth\Contracts\JWTSubject;

Add JWTSubject to the class implements clause, which will make it similar to:

class User extends Model implements AuthenticatableContract, AuthorizableContract, JWTSubject

Now add the following methods for JWT handling

* Retrieve the identifier for the JWT key.
* @return mixed
public function getJWTIdentifier()
return $this->getKey();
* Return a key value array, containing any custom claims to be added to the JWT.
* @return array
public function getJWTCustomClaims()
return [];

Create Authentication Controller

The Authentication controller will handle both the registration of new users and creation/refreshing of the JWT tokens.

I am not going to go through the AuthController line by line. It will be similar to any other auth controller, with a few tweaks for the JWTs.

Create the app/Http/Controllers/AuthController.php file and place in the following code:

namespace App\Http\Controllers;use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Auth;class AuthController extends Controller
{ public function __construct() {
$this->middleware(‘auth’, [‘except’ => [‘login’, ‘register’, ]]);
} /**
* Attempt to register a new user to the API.
* @param Request $request
* @return Response
*/ public function register(Request $request)
// Are the proper fields present?
$this->validate($request, [
‘name’ => ‘required|string|between:2,100’,
‘email’ => ‘required|string|email|max:100|unique:users’,
‘password’ => ‘required|string|min:6’,
]); try {
$user = new User;
$user->name = $request->input(‘name’);
$user->email = $request->input(‘email’);
$plainPassword = $request->input(‘password’);
$user->password = app(‘hash’)->make($plainPassword);
$user->save(); return response()->json([‘user’ => $user, ‘message’ => ‘CREATED’], 201);
} catch (\Exception $e) {
return response()->json([‘message’ => ‘User Registration Failed!’], 409);
} /**
* Attempt to authenticate the user and retrieve a JWT.
* Note: The API is stateless. This method _only_ returns a JWT. There is not an
* indicator that a user is logged in otherwise (no sessions).
* @param Request $request
* @return Response
public function login(Request $request)
// Are the proper fields present?
$this->validate($request, [
‘email’ => ‘required|string’,
‘password’ => ‘required|string’,
]); $credentials = $request->only([‘email’, ‘password’]); if (! $token = Auth::attempt($credentials)) {
// Login has failed
return response()->json([‘message’ => ‘Unauthorized’], 401);
} return $this->respondWithToken($token);
} /**
* Log the user out (Invalidate the token). Requires a login to use as the
* JWT in the Authorization header is what is invalidated
* @return \Illuminate\Http\JsonResponse
public function logout() {
return response()->json([‘message’ => ‘User successfully signed out’]);
} /**
* Refresh the current token.
* @return \Illuminate\Http\JsonResponse
public function refresh() {
return $this->respondWithToken( auth()->refresh() );
} /**
* Helper function to format the response with the token.
* @return \Illuminate\Http\JsonResponse
private function respondWithToken($token)
return response()->json([
‘token’ => $token,
‘token_type’ => ‘bearer’,
‘expires_in’ => Auth::factory()->getTTL() * 60
], 200);}

Set up the Routes

Next up is some routing! We are almost done!

Open up the routes/web.php file and add the following to it:

$router->post( ‘/login’, ‘AuthController@login’);
$router->post( ‘/register’, ‘AuthController@register’ );/*
‘middleware’ => ‘auth’,
], function( $router ) {
$router->post( ‘/logout’, ‘AuthController@logout’ );
$router->get( ‘/refresh’, ‘AuthController@refresh’ );
$router->post( ‘/refresh’, ‘AuthController@refresh’ );

Note: You can do this in routes/api.php if you would like. That will cause a prefix of `api/` in the URL


That is it! You now have user authentication and authorization set up on your Lumen API. Congrats!

Boat Delivery – San Pedro, CA to Tacoma, WA – trip log

Day One – Aug 15, 2020

Long day today. Woke up at 3 am and hopped on a flight from SeaTac, WA to Long Beach, WA. Planned to sleep on the first flight, then on the layover in Oakland, CA to get coffee and breakfast. All went to plan until Oakland. The airport had significant portions physically closed off, including all the restaurants and coffee shops…

Finally arrived in Long Beach and went right to the boat.

42’ Uniflite. The boat had been working as a commercial dive boat, taking groups out to Catalina Island. It was somewhat modified. Customers dropped off over the last couple of years and the boat was retired. The owner is moving it up to Tacoma, WA, and converting it back into a pleasure craft for cruising Puget Sound. There was still a lot on it from it’s working days. We hauled a lot off the boat.

Later met a great guy named Gary. Friend of the owner. He had a beautiful 75’ Chris Craft. We ran hard today. I was ready to drop at midnight. Fell asleep before my head hit the pillow.

I never did get any coffee…

Provisioning Day – Aug 16, 2020

Fiddled with the AIS this morning. Something wonky going on. It is transmitting, but also throwing an error. Turned out that the antenna was being shared by the VHF and it did not like that. Acquired a new antenna to install tomorrow.

Jesse and I went on a provisioning run. The amount of food we purchased is astonishing. I lived on a 33’ powerboat with my wife, and we cruised. 36’ sailboat. We always had plenty of food,  but it was tight. This boat is 42’ and has a full-sized fridge and freezer! There is also a large chest style fridge for drinks. It is provisioned to the gills with so. Much. Food. 

Tomorrow is the final prep day. We need to install then antenna, retrieve spare parts from a storage shed, finish resealing one window, and secure loose items, then we are ready to head out! Santa Barbara will be the first stop. Short hop/shakedown cruise.

Final Prep Day! – Aug 17, 2020

All the last minute projects got completed today. 8 person self-inflating life raft was installed, and Jesse did a stellar job of cleaning the carpets. 

My good childhood friend, Josh, moved down in this area a couple of years ago. Had not seen him since. He was able to stop by after work for a couple of hours. I missed that guy!

Missing Alix and Clara too. Clara gets excited to see me on FaceTime. She is 16 months old and adorable as heck.

All systems go to cast off in the morning!

Cast Off! – Aug 18, 2020

Left the dock at 7:31 AM

Big fog rolled into the harbor, but between the remaining visibility and our radar, we were not to be stopped.

Running at about 15 knots on fairly smooth seas. Engines running strong. They are brand new Cummins Diesels with only about 500 hours on them.

We got into Santa Barbara marina around 2 pm. Today was intentionally short. About 80 miles. We can run 150 without issue. Shakedown cruise for the owner. 

Every boat in this marina is nice. A big change from San Pedro, where most boats looked like derelicts. Walked passed an amazing sailboat, Moody DS54. Would be a fantastic offshore boat. Perfect for this area. The cabin was massive. Good for entertaining and cruising. On sailboats with that big of a hull, the sail-ability of the boat usually suffers, but this boat had a massive rig. It looks like it could be a good performer.

Getting up early in the morning and making a fast run to either San Lius Abisbo or Morro Bay, depending on the weather. There is a big northerly coming in in the afternoon that we want to avoid. The boat can handle it, but it would be a very uncomfortable ride.

Day Two – Santa Barbara to San Luis Obispo – Aug 19, 2020

Up at 6 am, left the dock at 7am. The seas here are very calm.

Passed an oil rig that was stinking up the whole area. It leaked something and the water around for miles had a smelly sheen on it. 

Weather is looking good for a run to San Luis Obispo. When we get close we will reevaluate Morro bay and head there, depending on the weather. I would prefer to get further north myself and do not mind bumpy conditions for a couple hours.

We headed to Morro bay. Had a few interesting moments. This boat is capable of 24 knots, which is pretty quick. We had been cruising at 17 knots when the waves started building. I was using the head (boat speak for toilet) and just pulling my shorts back up when we launched off a big wave. Suddenly I was floating weightless! It felt like minutes, but it was likely 5-10 seconds. We hit the bottom hard and I crumpled to the floor. No damage done to me or the boat.

Winds whipped up shortly after that. The storm that was coming in arrived a couple hours early and made life uncomfortable. 

Whales! There were a lot of humpback whales playing around us, spread out for miles. Blowing spouts, slapping tails, and a bunch of full body breaches as they jumped out of the water. It was fun to watch and a nice distraction from the lumpy water.

I have only been seasick once that I can recall. It was just off Washington, as the sailboat I was on turned South. The water was all mixed up and it was like being in a washing machine. In those conditions being inside the cabin is one of the worst places you can be. I stayed up on the flybridge, but even so, the stomach felt a little funny.

Once we got around the point the water calmed down and we were able to punch up to 17 knots again. During the slop we had slowed to 12 knots to prevent slamming into the waves.

Morro Bay Layover Day – August 20, 2020

The storm out on the coast is blowing gale force (25 mph+) winds and there are 8-foot waves at 5 seconds apart. That would make for a very uncomfortable and dangerous ride. We are sticking in Morro Bay today.

Harold and I walked the town. 20,000 steps in! Founds some nice gifts for ourselves and our ladies.

The forest fires are filling the area with smoke, blocking the sun and lowering the temperature. It was chilly. I wound up in my sleeping bag with pants and a sweatshirt on!

Morro Bay Layover Day Part Two – August 21, 2020

Today is the 5th year anniversary of the wedding with my lovely and wonderful wife, Alix Lobaugh. The last 5 years have been some of the most amazing and formative years of my life. Thank you, Alix. I love you!

We are sticking to Morro Bay for one more day. Conditions are not horrible out there, but would not be comfortable. Tomorrow looks promising, with the following days forecast to be quite pleasant. It hope they are and we can make some good headway. I would like to be able to finish the trip and not have to fly home. California is a long state! It takes several days to traverse. Once we get to Oregon and Washington the trip will move much faster.

Onward! A dash to Monterey Bay – August 22, 2020

We boogied out of Morro Bay around 7 am. Waters were initially calm. All was calm and boring in the beginning, but we knew that somewhere near Point Sur the water conditions would go to crap, and boy did they. The waters pile up from two directions around the point and collide together, making for some very confused and lumpy seas. We had to slow quite a bit to keep from slamming down. Ran at 10 knots. It was a lumpy and uncomfortable ride till we got around the point. To add to the misery, a thick fog rolled in.

Just past Point Sur a 15 foot Bayliner flagged us down. Three men were fishing and had become lost in the fog. We stopped to help them. Only one could speak English, and that barely. They followed us into the Monterey marina. The direction they were heading before was taking them AWAY from land. That would not have made for a happy day.

Cruised past the Monterey Bay Aquarium where my sister-in-law works. She saw us through the binoculars.

Monterey Bay to Bodega Bay – August 23, 2020

Pulled away from the dock about 7am. Headed from Monterey Bay to Bodega Bay.

This will be our longest single push. About 120 nautical miles. The seas started calm and we have been making 17 knots. There was a thick fog in Monterey Bay that dropped visibility to 150 feet or so. 

Made it safely to Bodega! Little fishing marina. I like the people here. Some neat looking fishing boats. I think If I lived on the ocean I would be happy to have a commercial fishing vessel.

The fog lifted some out of Monterey Bay, then came back with a vengeance and stayed with us till Bogeda Bay, where it magically lifted. 

No heater in the boat. I was freezing. Could not find my jacket. Looked all over my bunk and suitcase. Guess what…. after shivering for hours, we get to the dock and the jacket was hanging right above my head. What a dummy I am!

There was some debate about Fort Bragg or Eureka tomorrow. Eureka would be a long slog. We could make it, but Cape Mendocino is right before Eureka and we may take a beating there. After most of a day out there it would be rough. So Fort Bragg it is.

Getting near the end of my work vacation time. Did some future scoping- I think that I may make it, but the weather has to 100% cooperate with us. I would like to finish the trip!

Bodega Bay to Fort Bragg – Aug 24, 2020

Left the dock around 7am again. The weather is looking ok early in the day. 

Mostly easy going for the morning. Saw a pod of Blue Whales and Fin Whales traveling together. That was pretty neat! The Blue Whale is the largest mammal on earth, coming in  nearly 100 feet long. That is more than twice the length of the boat!

We ran into a fog wall. Amazing how one second you are in clear skies and the next a murky white soup. As soon as we entered we were hit by two very steep eight-foot tall waves. Of course, they came from different directions too… gave us quite the knocking about. There were a lot of fishing vessels in the fog. We passed nearby them and never saw another boat the whole time, the fog was that thick. 

Fort Bragg is crazy. Whoever thought of putting a marina and building a fishing village here was nuts. The entrance is between rocks and has lots of wave action. Once you get inside that, there is barely enough room for two boats side by side. The channel is dredged to about 20 feet, though there were many times it was a lot shallower.  If you miss the marina entrance you will almost immediately go aground! 

We ran into a snag with fuel. There is no fuel dock. You have to call a fuel truck and they will come to you. Unfortunately there is no truck till 1pm tomorrow. 

Cape Mendocino is the next leap. It has a bad reputation, justly received. When I brought the sailboat down we nearly got stuck there from a strong north-flowing current. 

The weather window to get around the cape is early in the morning tomorrow. Miss it and the wait is almost a week before it is safe again. We missed it. 

I am unfortunately throwing in the towel. My journey with Second Stage ends here. It will not be possible to make it home inside my time table. I am bummed about it. Even if we were able to leave, there are several other stops along the trip that we would get stuck at due to weather. They will not be home for at least another week, maybe two.  

As luck has it, there is one car rental place in the area and it is two miles from the marina. I put in a reservation to get a car tomorrow. Will fly home from Sacramento. 

The journeys end – Aug 25, 2020

Renting a car at Fort Bragg and driving it to the Sacramento airport. 

There is one rental car agency in the area. I made the two-mile walk and their lot was empty! Wanted to leave at 10 am. Turns out that most people rent cars one way, away from here. There is a truck on the way, carrying cars back. 

11:30 am the cars arrived! The lady here was extra nice and offered me my pick of any car at no additional cost. I picked the brand new Dodge Challenger. Looks fun.

Back to the boat to pick up my bags, then heading toward the airport!

The drive from Fort Bragg to Sacramento airport took four hours. There are lots of curves on the 20. That Challenger was super fun to drive. It swept around the corners with style and grace. Smoke from the forest fires in California was intense. It was like driving through a thick brown fog for 4 hours. Luckily the A/C system in the Challenger had a recirculation mode that worked excellently.

Was delayed by some construction. Flight at 5:20 pm, arrived at the rental drop off at 4 pm. I was sweating for time, but everything went smoothly from here. Walked to the shuttle area and one was there- stepped aboard and the door shut. Nobody was in the bag drop off line. Took the escalator up to the train to TSA and it left right as I stepped on. Walked right through TSA. Made it to my gate with 15 minutes to spare before boarding! Just enough time to grab some chicken nuggets. 4:30 pm. I had not eaten all day!

Nano Reef 13 gallon cleaned up

Nano Reef cleaned up!

The tank is doing great now! It has been several weeks without new issues cropping.

I did lose the Watchman Goby, everybody else is quite healthy though 🙂

The fish and invertebrates are still in the tank, just hiding in this photo.

Page 3 of 168

Powered by WordPress & Beards