Friday, July 24, 2026

Docker, Apache HTTPd, and Perl with DBI

Okay.  I swore about crap all the day long, trying to get CGI's to write to a database using the default httpd:2.4 docker image.

NOTHING worked.

Until I found some obscure post referencing extending an image, I was at a loss.  Then, with the reference to "extending an image", I knew what to search for, and finally got it.  Here's what to do.  You can "create" a new image using a "build", e.g. create a Dockerfile containing :

    # Sample Dockerfile for extending a container
    FROM httpd:latest
    RUN apt-get update && apt-get install -y libdbd-mysql-perl
    CMD ["httpd-foreground"]

The file says to take the httpd:latest, run a command on it, then set the command to run (could already be there, I don't know - it just worked and make me very happy).

Then, run :

    docker build -t httpd:mysql .

After that, change your docker-compose.yml to reference your new image, and then you should be able to make it function.  Granted, CGI's need the REMOTE_USER if you are doing anything specific with that, so add the config where you need it to pull the authenticated user from the Cloudflare header containing the username and stuff it.

    # set the REMOTE_USER variable
    RewriteCond %{HTTP:Cf-Access-Authenticated-User-Email} ^(.*)$
    RewriteRule .* - [E=REMOTE_USER:%1]

Then stop the container and start it back up, and that part should be functional.  The bonus is you can extend all you want. 

Cloudflare (Personal) for Secure Tunnel + Specific Google Account Access

I'm working on getting rid of my virtual server at a service provider (cheaper, right), and I don't want to shift to a static IP or an open firewall.  NetworkChuck posted a video about setting up a "free" account with Cloudflare for Zero Trust, and that resulted in this direction.  So, here we go.

I signed up at Cloudflare following NetworkChuck's video (there were some differences - they've changed their website since he posted the video).  Then, once I had the DNS transferred over and detected, it was time to start setting it up.

Note, you still have to provide a credit card (or other method for payment such as PayPal or a bank) and "sign" the form indicating that you will pay for services that exceed the "free" version limits.  That means you could stop now if you don't want to proceed.

Okay.  Once you have your site added and the DNS transferred, on the left side menu (toward the bottom) is a "Zero Trust" link.  Click that.  You will work through their wizard for activating Zero Trust (including the above bank/card information).

Once complete, click on "Networks" on the side to expand it, and you'll see "Tunnels and Mesh".  Click on that.

Then, click "Add a Tunnel".  I selected the Cloudflare tunnel rather than a mesh tunnel.  Provide a name, then save the tunnel.

Now, if you aren't using Docker, you can select the OS type (e.g. RedHat, Debian, Windows), all of which have the instructions on installation.  Even though I am working on RedHat, I went with the Docker OS type. This will present you with the commands to run.  In order to make the docker image background, after the FIRST run in there (because the Docker image has another command for "run"), add a -d (for detach).

    docker run -d cloudflare/cloudflared:latest tunnel --no-autoupdate run --token [TOKEN]

I'd recommend storing your token somewhere safe.  Really.  Safe.

The command will start a docker image.  Now, just to be sure that this image starts after a power outage, I ran an update to the policy.  Get the policy name from docker first, then run the update :

    docker ps
    docker update --restart unless-stopped container_name

This should make it permanent.  I did rename my "docker container rename CONTAINER NEW_CONTAINER_NAME".

 Now, I followed NetChuck's instructions on the last step of setting up the tunnel a little too exact, and had some problems (bad gateway error).

So, getting the logs revealed that :

    docker container logs cloudflare -f

The above command dumped the logs and kept watching for more log updates (like tail -f), so I knew what the problem was.

    2026-07-23T15:39:03Z ERR  error="Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp 10.0.17.45:8006: connect: connection refused" connIndex=2 event=1 ingressRule=0 originService=https://10.0.17.45:8006
    2026-07-23T15:39:03Z ERR Request failed error="Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp 10.0.17.45:8006: connect: connection refused" connIndex=2 dest=https://hostname.domain/favicon.ico event=0 ip=131.67.22.37 type=http

It turns out the URL wasn't for the Cloudflare service, but actually for the web service I needed to connect to.  Duh.

Okay, under "Tunnels & Mesh", click on "Published application routes" at the top, then the three dots to the right of the connection we just set up, and then "Edit". 

Once fixed, save that thing, and reload it.

Now, since we don't have authentication, we'd better get OpenIDC authentication going.  I fought long and hard trying to get the Apache HTTPd docker image to load the mod_auth_openidc module so I could configure it that way, but I stopped short of rebuilding an entire httpd specifically for that.  I was getting errors loading the module if I manually mapped it from an existing ubuntu image, but that gave me errors about dynamic .so file load failures for libcrypt as well.

I rapidly gave up and in the Cloudflare dashboard (https://dash.cloudflare.com/), I went under Zero-Trust, and then hit "integrations".

In there, click on "Identity Providers" followed by "Add an Identity Provider", and then fill out the information you get from your Google (or whatever service provider you are using).  Hit the "test" button.

If it gives you an error when testing, such as :

If it gives you an error, get more details (Google likes to add a link about if you are the application developer, and that will provide a redirect URL - copy that).  Then, go into your application provider where you set up your OAuth2.0 key, and add it as a redirection URL.  If it complains about a space in it and refuses to let you save it, you've likely copied too much - just whack everything after the "callback" where the space starts, and then save it.

Next, go under "Access Controls", and "Applications".  Follow the wizard to create a new "application" - it can be a sub URI, or you can lock down the whole site (which is what I did).

Here, you will also create a policy (I did a policy that only allows specific Google e-mail addresses, both those that are in a google domain as well as one or two that are outside of it).

Once that is done, before you finish the application, if you are only authenticating against one source, I'd recommend selecting that source and not showing the prompt for multiple sources.  However, if you are going to accept users from other locations (such as Entra, etc), leave those as-is. 

Suddenly, you should have immediate access if you've logged in to Google.

The next thing I had to figure out was how to get Grafana to recognize the user.  And that meant going into the Grafana docker and changing the GF_AUTH_PROXY_HEADER_NAME value :

     - GF_AUTH_PROXY_HEADER_NAME=Cf-Access-Authenticated-User-Email

After restarting the docker image, this let me use the username in the dashboards for Grafana, and view my data.  Sweet!

Wednesday, July 22, 2026

Docker and MQTT (Mosquito)

My Mosquito implementation is not heavily modified.  In fact, it's so simple, I could do a :

    docker run -d ---name mosquitto -p 1883:1883 eclipse-mosquitto:2

This would be just fine.  However, I want my docker image to restart after reboots, and I'm too lazy to go about it any other way than a docker-compose.yml :

    # docker-compose.yml - Mosquitto MQTT broker
    services:
    mosquitto:
    image: eclipse-mosquitto:2
    container_name: mqtt
    ports:
    # Standard MQTT protocol
    - "1883:1883"
    # MQTT over WebSocket
    - "9001:9001"
    #volumes:
    # Mount configuration directory
    #- ./config:/mosquitto/config
    # Persist message data
    #- ./data:/mosquitto/data
    # Persist log files
    #- ./log:/mosquitto/log
    restart: unless-stopped

Note the entire "volumes" section has been commented out.  I started to add it, and realized I didn't care about persisting message or log data, nor was I running a custom configuration.  So, I left it out.

Then, start it up in the usual fashion with a :

    docker compose up -d

And off you go! 

Tempest Weatherflow - Data Mapping into MySQL

I'm slowly migrating off of a virtual server at a service provider.  Most things have been moving to docker.  In this case, it's going onto the docker server, but not going to run in docker itself.

It has to be on the same network as the Weatherflow device to be able to pick up data, and me keeping it on the unrouteable network meant this docker platform needed to be on two separate networks.  So, I fired off the command to create a new connection on the same interface.

    nmcli connection add con-name unrouteable ifname enp1s0 type ethernet ipv4.method manual ipv4.addresses 192.168.2.10/24 ipv4.never-default yes

Unfortunately, this resulted in the system not being able to boot onto the network.  So, it was backed out, and I moved the weather hub off of the unrouteable to the regular network.  The code below, I simply could NOT get to arrive in any docker image, or running directly on the docker server (outside of a container).  However, after a lot of troubleshooting to verify that the broadcast packets were getting there using :

    sudo tcpdump -i enp1s0 udp port 50222 -Xvvv

I finally realized that RedHat had firewall-cmd running by default.  Now, I'm trying to make all this work with defaults, which means that instead of disabling it, I'm going to open the port :

    firewall-cmd --permanent --add-port=50222/udp
    firewall-cmd --reload
    firewall-cmd --list-all

You should see the ports: line now containing "50222/udp".  And, if you run the server, it should show it's receiving packets.  Okay, back to building this "listener".  I went down this whole path of making it run an exec() to curl, which made it really easy to send the received JSON to a perl script and customize whatever you needed from there.  Yes, at the very bottom of the code I have commented out a "sendJSON" function, and instead I'm using a handleJSON function that will do the parsing, so if you need to flip that switch because you are using a non-docker version of HTTPd and Perl, you can install the package and make it happen.

Since the Perl instances in the Apache/HTTPd 2.4 instances ("httpd 2.4") do NOT include either the Perl JSON module, or the DBD::MySQL module, it ultimately means you can't send this to Apache and deal with it nicely there if you are also using native docker.  That leaves one choice - write a UDP "listener" (duh, UDP packages don't "listen" on UDP ports, because there is no connection/handshake).

So, I wrote the dumb thing in perl. I'd already had the UDP side, but since it might as well do the local database insertion, whelp, that's what I did.

Fancy thing is that I can specify which datagram types to accept from the Tempest system.  I really only care about lightning strikes and obs_st (standard metrics).  But, I slapped SQL for all, and did a fancy ".disabled" extension to hide the ones I wanted.  If I don't have a disabled, when it sees a packet, it will dump the hash_ref so that I can easily write one if I need it.  I just wrote what I saw, then disabled most of them.

Here's the code for the listener :

    #!/usr/bin/perl -w

    # written by Joe Lewis <joe@joe-lewis.com> on November 29, 2022
    # A UDP listener for the tempest weather system. The API for weatherflow (maker
    # of the tempest system) can be found at :
    # https://weatherflow.github.io/Tempest/api/udp.html
    # if on RHEL, install :
    # perl-libwww-perl
    # perl-Sys-Syslog.x86_64
    # for RHEL, this uses the template tool kit (available in the EPEL repository)
    # sudo subscription-manager repos --enable codeready-builder-for-rhel-9-x86_64-rpms
    # sudo dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
    # sudo yum install perl-Template-Toolkit.x86_64


    use strict;
    use warnings;
    use Data::Dumper;
    use DBI;
    use Getopt::Long;
    use HTTP::Request;
    use HTTP::Response;
    use IO::Socket;
    use JSON;
    use LWP::UserAgent;
    use POSIX;
    use Sys::Syslog qw(:DEFAULT :standard :macros);
    use Template;


    my $template_directory = 'sql';
    my $mysql_hostname = '127.0.0.1'; # if you use "localhost", it will try the unix socket
    my $mysql_username = 'user_only_having_insert_or_update';
    my $mysql_password = 'users_password';
    my $mysql_database = 'weather';

    my $verbose = undef;
    my $daemonize = undef;

    GetOptions(
    'sql-directory=s' => \$template_directory,
    'daemonize' => \$daemonize,
    'verbose' => \$verbose,
    );


    my($sock, $newmsg, $MAXLEN, $PORTNO);
    my $json = JSON->new->allow_nonref;
    my $userAgent = LWP::UserAgent->new(timeout => 10);
    my $postURL = 'https://protected.silverhawk.net/cgi-bin/weatherflow.pl';
    my $postContentType = 'application/json';
    my $name = 'WeatherFlow-UDP-conv';
    my $nagios_command_file = '/var/lib/nagios3/rw/nagios.cmd';
    my $error_log_qfn = '/var/log/'.$name.'.log';
    my $pid_file_qfn = '/var/run/'.$name.'.pid';

    $MAXLEN = 1024;
    $PORTNO = 50222;

    sub sighup_handler {
    exit;
    };

    sub sigusr1_handler {
    exit;
    };

    sub logMessage {
    my $msg = shift;
    $msg .= "\n" unless $msg =~ /\n/;
    syslog('info',$msg);
    };

    sub handleUDPPacket {
    my $weatherstationname = shift;
    my $weatherstationclient = shift;
    my $data = shift;
    my $href = $json->decode($data);
    my $weatherstationip = inet_ntoa($weatherstationclient);
    # just for my sanity, insert the hostname
    $href->{'client_hostname'} = $weatherstationname;
    $href->{'client_ip'} = $weatherstationip;

    my $sqlTemplate = $template_directory.'/'.$href->{type}.'.sql';
    if (-f $sqlTemplate) {
    my $tt = Template->new({INCLUDE_PATH=>$template_directory,INTERPOLATE => 1}) or die "Error launching the template toolkit : $Template::ERROR\n";
    # establish a MySQL connection
    my $dbh = DBI->connect('dbi:mysql:host='.$mysql_hostname.':database='.$mysql_database.':mysql_ssl=1',$mysql_username,$mysql_password);
    $href->{dbh} = $dbh;
    my $SQL;
    $tt->process($href->{type}.'.sql',$href,\$SQL) or warn 'Template Processing Failure: '.$tt->error()."\n";
    if ($SQL) {
    $dbh->do($SQL) or warn "Error running SQL on type $href->{type}: $DBI::errstr, SQL :\n\t$SQL\n";
    }
    $dbh->disconnect();
    } else {
    # if we have a disabled SQL file, do nothing because we're intentionally swallowing it.
    if (!-f $sqlTemplate.'.disabled') {
    # no template defined, warn it
    warn "non-existant SQL template $sqlTemplate containing:\n".Dumper($href);
    }
    };
    };

    openlog("",'ndelay','daemon');
    if (defined($daemonize)) {
    my $pid = fork();
    exit if ($pid > 0);
    setsid();
    $pid = fork();
    exit if ($pid > 0);
    };
    $SIG{HUP} = \&sighup_handler;
    $SIG{USR1} = \&sigusr1_handler;

    $sock = IO::Socket::INET->new(
    LocalPort => $PORTNO,
    Proto => 'udp') or die "socket: $@";

    logMessage("Awaiting UDP messages on port $PORTNO");

    while ($sock->recv($newmsg, $MAXLEN)) {
    my($clientport, $clientaddr) = sockaddr_in($sock->peername);
    my $clienthost = gethostbyaddr($clientaddr, AF_INET);
    handleUDPPacket($clienthost,$clientaddr,$newmsg);
    }
    my $status = $!;
    logMessage("Loop completed, exiting with $status");
    die "recv: $status";

Now, that's relatively simple.  I created the separate directory for the SQL's specific to the Tempest functionality, and then piled a bunch of files in there :

    *** sql/device_status.sql.disabled
    INSERT INTO device_status (timestamp,serial_number,client_ip,client_hostname,hub_sn,voltage,rssi,hub_rssi,sensor_status,firmware_revision) VALUES (from_unixtime([% timestamp %]),[% dbh.quote(serial_number) %],[% dbh.quote(client_ip) %],[% dbh.quote(client_hostname) %],[% dbh.quote(hub_sn) %],[% voltage %],[% rssi %],[% hub_rssi %],[% sensor_status %],[% firmware_revision %])

    *** sql/evt_precip.sql.disabled
    INSERT INTO evt_precip (timestamp,station_id,hub_sn) VALUES (from_unixtime([% evt.0 %]),[% dbh.quote(serial_number) %],[% dbh.quote(hub_sn) %)

    *** sql/evt_strike.sql
    INSERT INTO lightning_events (epoch,station_id,energy,distance_km,distance_miles) VALUES (from_unixtime([% evt.0 %]),[% dbh.quote(serial_number) %],[% evt.2 %],[% evt.1 %],[% evt.1/1.6093445 %])

    *** sql/hub_status.sql.disabled
    INSERT INTO hub_status (timestamp,serial_number,frequency,rssi,client_ip,uptime,firmware_revision,hw_version,client_hostname,radio_version,reboot_count,i2c_bus_error_count,radio_status,radio_network_id) VALUES (from_unixtime([% timestamp %]),[% dbh.quote(serial_number) %],[% freq %],[% rssi %],[% dbh.quote(client_ip) %],[% uptime %],[% dbh.quote(firmare_revision) %],[% hw_version %],[% dbh.quote(client_hostname) %],[% radio_stats.0 %],[% radio_stats.1 %],[% radio_stats.2 %],[% radio_stats.3 %],[% radio_stats.4 %])

    *** sql/obs_air.sql.disabled
    INSERT INTO obs_air (timestamp,serial_number,firmware_revision,hub_sn,station_pressure_mb,station_pressure_inhg,temperature_celcius,temperature_farenheit,rel_humidity,lightning_strike_count,lightning_strike_avg_distance_km,lightning_strike_avg_distance_miles,battery_voltage,report_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% firmware_revision %],[% dbh.quote(hub_sn) %],[% obs.0.1 %],([% obs.0.1 %]*0.029530)+4.7634625,[% obs.0.2 %],([% obs.0.2 %]*1.8)+32,[% obs.0.3 %],[% obs.0.4 %],[% obs.0.5 %],[% obs.0.5 %]/0.621371,[% obs.0.6],[% obs.0.7 %])

    *** sql/obs_sky.sql.disabled
    INSERT INTO obs_sky (timestamp,serial_number,firmware_revision,hub_sn,illuminance_lux,uv_index,rain_previous_minute_mm,rain_previous_minute_in,wind_lull_mps,wind_lull_mph,wind_avg_mps,wind_avg_mph,wind_gust_mps,wind_gust_mph,wind_direction,battery_voltage,report_interval,solar_radiation,day_rain_accumulation_mm,precipitation_type,wind_sample_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% firmware_revision %],[% dbh.quote(hub_sn) %],[% obs.0.1 %],[% obs.0.2 %],[% obs.0.3 %],[% obs.0.3 %]/25.4,[% obs.0.4 %],[% obs.0.4 %]*2.23694,[% obs.0.5 %],[% obs.0.5 %]*2.23694,[% obs.0.6 %],[% obs.0.6 %]*2.23694,[% obs.0.7 %],[% obs.0.8 %],[% obs.0.9 %],[% obs.0.10 %],[% obs.0.11 %],[% obs.0.12 %],[% obs.0.13 %])

    *** sql/obs_st.sql
    INSERT INTO twenty_four_hour (epoch,station_id,wind_lull_metric,wind_lull_standard,wind_avg_metric,wind_avg_standard,wind_gust_metric,wind_gust_standard,wind_direction,wind_sample_interval,barometric_pressure_milibars,barometric_pressure_inHg,temperature_celcius,temperature_farenheit,relative_humidity,illuminance,ultraviolet_index,solar_radiation,rain_amount_metric,rain_amount_standard,precipitation_type,lightning_avg_distance_metric,lightning_avg_distance_standard,lightning_strike_count,battery_voltage,report_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% obs.0.1 %],[% obs.0.1 %]*2.23694,[% obs.0.2 %],[% obs.0.2 %]*2.23694,[% obs.0.3 %],[% obs.0.3 %]*2.23694,[% obs.0.4 %],[% obs.0.5 %],[% obs.0.6 %],([% obs.0.6 %]*0.029530)+4.7634625,[% obs.0.7 %],([% obs.0.7 %]*1.8)+32,[% obs.0.8 %],[% obs.0.9 %],[% obs.0.10 %],[% obs.0.11 %],[% obs.0.12 %],[% obs.0.12 %]/25.4,[% obs.0.13 %],[% obs.0.14 %],[% obs.0.14 %]/0.621371,[% obs.0.15 %],[% obs.0.16 %],[% obs.0.17 %])

    *** sql/obs_st.sql.original
    INSERT INTO obs_st (timestamp,serial_number,firmware_revision,hub_sn,wind_lull_mps,wind_lull_mph,wind_avg_mps,wind_avg_mph,wind_gust_mps,wind_gust_mph,wind_direction,wind_sample_interval,station_pressure_mb,station_pressure_inhg,temperature_c,temperature_f,relative_humidity,illuminance_lux,uv,solar_radiation,rain_previous_minute_mm,rain_previous_minute_in,precip_type,lightning_strike_avg_distance_km,lightning_avg_distance_miles,lightning_strike_count,battery,report_interval) VALUES (from_unixtime([% obs.0.0 %]),[% dbh.quote(serial_number) %],[% firmware_revision %],[% dbh.quote(hub_sn) %],[% obs.0.1 %],[% obs.0.1 %]*2.23694,[% obs.0.2 %],[% obs.0.2 %]*2.23694,[% obs.0.3 %],[% obs.0.3 %]*2.23694,[% obs.0.4 %],[% obs.0.5 %],[% obs.0.6 %],([% obs.0.6 %]*0.029530)+4.7634625,[% obs.0.7 %],([% obs.0.7 %]*1.8)+32,[% obs.0.8 %],[% obs.0.9 %],[% obs.0.10 %],[% obs.0.11 %],[% obs.0.12 %],[% obs.0.12 %]/25.4,[% obs.0.13 %],[% obs.0.14 %],[% obs.0.14 %]/0.621371,[% obs.0.15 %],[% obs.0.16 %],[% obs.0.17 %])

    *** sql/rapid_wind.sql.disabled
    INSERT INTO rapid_wind (epoch,station_id,speed_mps,speed_mph,direction) VALUES (from_unixtime([% ob.0 %]),[% dbh.quote(serial_number) %],[% ob.1 %],[% ob.1 %]*2.236936,[% ob.2 %])

And to ensure it starts on boot (this is outside of docker - meh, I was so frustrated that I'm fine with it), I created an /etc/systemd/system/weatherflow_udp.service file containing :

    [Unit]
    Description=UDP Listener for Tempest Weatherflow
    After=default.target

    [Service]
    Type=oneshot
    ExecStart=/where/ever/you/stored/it/tempest/perl/udp_listener.pl --daemonize --sql-directory /where/ever/you/stored/it/tempest/perl/sql/

    [Install]
    WantedBy=default.target

And then enabled the service :

    systemctl daemon-reload
    systemctl enable weatherflow_udp

Viola (or Cello, I don't care), I have data!

One step closer!

Docker and WeeWx

WeeWX - Weather Station Data Gathering

Note, I eventually gave up on installing the weatherflow-udp extention under docker, and just wrote a UDP-to-CGI bridge tool, documented later.  However, I opted to post this anyway because of what I ran into and it could be useful for someone out there (I got fairly close, actually).

This is an interesting tool to accept data for the weather system. I'm using a weatherflow tempest, which means I have a UDP port I have to translate.  Plus, it is a little more complicated than the others we've dealt with.  First, let's create a pre-run form of docker-connect.yaml :

    services:
      weewx:
        image: felddy/weewx:latest
        container_name: tempest
        restart: unless-stopped
        ports:
          - "50222:50222/udp" # Map WeatherFlow UDP port
        environment:
          - TZ=America/Boise
        volumes:
          - type: bind
            source: ./weewx-data
            target: /data 

Then, let's create our data directory to grab configuarion files from :

    mkdir weewx-data/
    chown 1000:1000 weewx-data

Now we can run a "fake" start of the docker image to get the configurations we need :

    docker compose run --rm weewx

At this point, the weewx-data/weewx.conf file is ready for manipulation.  Copy the weewx.conf into the docker image directory, then modify it by changing the station name, the latitude and longitude, etc.  Once ready, change your docker-compose.yml file by adding the bind volume for the weewx.conf file, e.g. :

        volumes:
          - type: bind
            source: ./weewx-data
            target: /data

becomes :

        volumes:
          - type: bind
            source: ./weewx-data
    target: /data

Then, start the docker (usual "docker compose up -d").

It is now possible to run and install extensions.  First, just to make sure it's showing, run :

    docker exec -it tempest weectl extension list

Then install with : 

    docker compose run --rm weewx extension install --yes https://github.com/captain-coredump/weatherflow-udp/archive/refs/heads/master.zip 

Now, it was right about here that I gave up on getting the extension installed.  I'm leaving my notes, just in case.  But really, all I need is a UDP translator, anyway.  I'm not trying to do anything fancy like serve weather data (that will all happen through Grafana, so, yeah).\

On giving up, I just wrote a UDP listener.

Docker and Grafana

Managers love pretty pictures, right?  I know, this is a personal intranet, but hey, why not?

Grafana was a bit more of a challenge to run in docker.  I have issues with it being a default non-SSL port, but we can potentially fix that by throwing Apache in front of it down the road.  But, it's the same process as before, but with a major caveat.

We're not doing much special here, but I do need to make a quick statement.  My Grafana was an older 9.something version.  If you run it in Docker, and top are not starting from scratch (e.g. you are going to copy from a backup), it will fail.  In this case, you MUST download a 12.3 package, and do a debian upgrade on that package, and stop/restart so that it can migrate.  THEN you can move to the Docker 12.4 image base.

Now, back to the regularly scheduled programming.

Simply copy over your /var/lib/grafana/grafana.db SQLite file.  If you have customizations in your grafana.ini file, you can make those changes to a grafana.ini file and map it just like we did with Apache HTTPd above.  Note that it will be better to do that old "cat" command we mentioned in Apache to get a copy and then modify that one.

My docker-compose.yml looks like :

     services:
      web:
        image: grafana/grafana
        container_name: grafana
        restart: unless-stopped
        ports:
          - "3000:3000"
    environment:
    - GF_SECURITY_ADMIN_USER: admin
    - GF_SECURITY_ADMIN_PASSWORD: admin
    - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana/
    - GF_SERVER_ENFORCE_DOMAIN=false
    - GF_AUTH_PROXY_ENABLED=true
    - GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER
    - GF_AUTH_PROXY_HEADER_PROPERTY=username
    - GF_AUTH_PROXY_AUTO_SIGN_UP=true
        volumes:
          - /opt/docker/storage/grafana:/var/lib/grafana:rw

Note the environment variables.

  • The SECURITY_ADMIN stuff sets  your default username/password.  If you are not migrating, and are instead creating a brand-new instance, these will set the default username/password.
  • The GF_SERVER_ROOT_URL is used to change the default URL (since Grafana is so smart it is going to redirect you to the Grafana port number, and this specifies whoat to hit with).
  • The GF_SERVER_ENFORCE_DOMAIN - this is where the AI engines kept biting me.  They had NO CLUE about this.  Grafana LOVES to redirect you, and if the enforce_domain in the grafana.ini uses the default or is set to true, it's doing to redirect you if you didn't hit it with the domain (could be hostname, and in docker, this defaults to "localhost").  So, if you are using docker, and if you are also reverse proxy'ing Grafana, which, in a reverse proxy environment, is not good).  It kept redirecting me to http://localhost:3000/ .  Setting this value via GF_SERVER_ENFORCE_DOMAIN allowed me to override the enforce_domain and shut that stupid thing off.
  • The GF_AUTH_PROXY_* environmment variables are to disable forcing a login.  If you are reverse proxy'ing Grafana, and the parent process is handling the authentication, this is likely what you will need to do in combination with the ReverseProxy configs I showed earlier that use the rewrite_module under Apache HTTPd.

Then it's a matter of starting it up again (the "docker compose up -d" command).

Docker and MySQL

In my pursuit of an easier, personal "intranet", my next task was to get MySQL running.  It's critical for the Grafana and Nagios instances that run my environmental monitoring.  There is a lot of historical (hysterical?) data involved, so we'll make it easier and just migrate it over, right?

First, create your docker directory for MySQL :

    mkdir -p /opt/docker/mysql

Then, create only one file in there, the infamous docker-compose.yml :

    services:
      mysql:
        image: mysql:latest
        container_name: mysql-container
        environment:
          MYSQL_ROOT_PASSWORD: super-secret-pwd
          # uncomment below if you want to automatically create and select a database
          # MYSQL_DATABASE: database_name
        ports:
          - "3306:3306"
        volumes:
          - /opt/docker/storage/mysql:/var/lib/mysql

Change the MYSQL_ROOT_PASSWORD row in the docker-compose.yml, and you're done.

Now, we are ready to spin that sucker up already - it really is that simple.

    docker compose up -d

Give it a minute to spin it up.  Then, test the connection (no MySQL Unix-socket will be available, so your MySQL command has to use the "--protocol TCP" option :

    [jlewis@docker mysql]$ mysql -u root -p -h localhost --protocol TCP
    Enter password: 
    Welcome to the MySQL monitor.  Commands end with ; or \g.
    Your MySQL connection id is 9
    Server version: 9.7.1 MySQL Community Server - GPL

    Copyright (c) 2000, 2026, Oracle and/or its affiliates.

    Oracle is a registered trademark of Oracle Corporation and/or its
    affiliates. Other names may be trademarks of their respective
    owners.

    Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

    mysql> show databases;
    +--------------------+
    | Database           |
    +--------------------+
    | information_schema |
    | mysql              |
    | performance_schema |
    | sys                |
    +--------------------+
    4 rows in set (0.01 sec)

    mysql> exit 

Create whatever you need in your database, and you have a modular database that can be quickly moved over to another instance of docker if CPU gets too high.  In my case, I'm migrating an existing MySQL database, so I'll run : 

    mysqldump -u root -p databasename > mysqldump-databasename.sql

Copy that to the location that can write this into the database, create the database ("CREATE DATABASE databasename" in the new MySQL server first) and import it :

    mysql -u root -p --protocol TCP databasename > mysqldump-databasename.sql

Doing pretty good so far. 

Docker - Apache HTTPd for my tiny Intranet

I'm rebuilding my network stack for my little intranet, and the first thing out of the gate is the HTTPd web service.  So, let's get started.

I'm not using NginX because there will be a lot more CGI, and also because I want to implement my template mechanism, a custom module for the Apache HTTPd serer.  Converting from an existing HTTPd configuration is a bit more difficult when it comes to migrating it in to Docker.

Now, RedHat and other Linux vendors/distributors love to part the httpd.conf file out into a whole lot of directories.

Docker doesn't do that.  However, we have a little secret.  So, let's create our docker image first.  Create your docker directory and then the container for this specific image.

    mkdir -p /opt/docker/httpd

Because the docker image is actually based on a Debian version, and because it includes an httpd.conf file already (by default), we only need three files in this directory.

For the httpd.conf, I found it easier to grab the httpd.conf file out of the docker image first, then add my custom module.  And, since I wanted the image to serve content from the NAS, I needed to get the web content from that mounted up.  Let's grab a copy of the docker container httpd.conf file.

    docker run --rm httpd:2.4 cat /usr/local/apache2/conf/httpd.conf > httpd.conf

Now, with that, I can add my own module using the LoadModule with any custom directives.  Then, we simply add the NFS mounts for the mount the NAS up via NFS, and then use volumes, too.  Enter the docker compose. 

    services:
      web:
        image: httpd:2.4
        container_name: httpd
        restart: unless-stopped
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - /opt/docker/storage/http/html:/usr/local/apache2/htdocs/:ro
          - /opt/docker/storage/http/cgi-bin:/usr/local/apache2/cgi-bin/:ro
          - /opt/docker/storage/http/logs:/usr/local/apache2/logs/:rw
          - ./certs:/etc/apache2/ssl
          - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
          - ./mod_template.so:/usr/local/apache2/modules/mod_template.so:ro

The container name/image, are fairly inconsequential - you can name them anything you want.  The default is "apache_service" for this from the Docker image, but I like to get very specific because Apache likes to do a lot of services.

The "restart" line is there in case the EliteDesk hardware loses power and reboots - I want this container to start back up immediately.

The ports are very easy to understand if you are used to TCP.  It is formatted as "HOST PORT:CONTAINER PORT".

Also, we need to set up SSL (note we have port 443 in there).  Create a certificate :

    mkdir certs
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout certs/httpd.key -out certs/httpd.crt -subj "/C=US/ST=Idaho/L=Boise/O=SilverHawk/CN=silverhawk.net"

You will need to set up the standard VirtualHost for it (make sure you uncomment the LoadModule line for the ssl extension while in there) :

    <VirtualHost *:443>
    DocumentRoot "/usr/local/apache2/htdocs"
    #ServerName www.example.com
    SSLEngine On
    SSLCertificateFile /etc/apache2/ssl/httpd.crt
    SSLCertificateKeyFile /etc/apache2/ssl/httpd.key
    </VirtualHost>

While I was there, I also added proxy and proxypass information for Grafana, (that's coming - note you need to enable xml2enc_module, proxy_http_module, and proxy_module, and make sure you disable proxy for everything except your own proxt so that someone doesn't suddenly use your site as a personal proxy to mask what they are doing on the Internet).

A grafana note :

I really strugged with proxy pass in this situation.  all of the AI engines kept telling me things that were not accurate.  So, here's what I have.  In my httpd.conf for docker, I have the following excerpt :

    ProxyRequests Off
    <VirtualHost *:443>
    DocumentRoot "/usr/local/apache2/htdocs"
    #ServerName www.example.com
    SSLEngine On
    SSLCertificateFile /etc/apache2/ssl/httpd.crt
    SSLCertificateKeyFile /etc/apache2/ssl/httpd.key

    RewriteEngine On
    RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER},NS]
    RequestHeader set X-WEBAUTH-USER "%{REMOTE_USER}e"
    ProxyPreserveHost On
    ProxyPass /grafana/ http://172.17.0.1:3000/
    ProxyPassReverse /grafana/ http://172.17.0.1:3000/
    RequestHeader unset Accept-Encoding
    <Location /charts>
    TemplateEnabled off
    </Location>
    </VirtualHost>

Then, in my Grafana docker-compose.yml file, I had to add the GF_SERVER_ROOT_URL and GF_SERVER_ENFORCE_DOMAIN environment variables (there are some others, to set up anonymous browsing since this will be front-ended by an authentication later) :

        environment:
    - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana/
    - GF_SERVER_ENFORCE_DOMAIN=false
    - GF_AUTH_PROXY_ENABLED=true
    - GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER
    - GF_AUTH_PROXY_HEADER_PROPERTY=username
    - GF_AUTH_PROXY_AUTO_SIGN_UP=true

Volumes are where the magic happens for the custom module and configuration as well as the web root.  Each row represents a specific file/directory that is mapped from the docker image.  For example, I have my mod_template.so in there so it gets mapped into the container, plus I am mapping an httpd.conf file, as well as two NFS directories so I don't have to restart the docker every time I change the website.

Then, it's simply a matter of launching it up.

    sudo docker compose up -d  

Note, if you run into errors, you can use (and there is a -f option to tail -f them) :

    docker compose logs

This will dump the containers log files (I suppose you COULD re-map log files if you want those pulled out of the image naturally, but this was easier). 

Tuesday, May 5, 2026

Flushing The Radiator

While preparing to wire in dual electric fans on a switch, I knew I needed to give the cooling system a clean bill of health.  I mean, what's an evening compared to peace of mind?  (Or is it supposed to be a piece of mind because I may have lost it?)

I slipped by the big box home improvement store, bought two 6-foot garden hoses, two 1-1/4" PVC reducers (to 1/2" female pipe thread), three short  1/2" pipes (2" in length, I think), one PVC ball valve, and two garden hose adapters (one male and one female, both with a female 1/2" pipe thread on the other end).

 I screwed it all together, and had a solid system, ready to flush the radiator.  I cracked the thermostat housing, and... realized a previous engine owner had mixed orange and green antifreeze (I'd filled with water at one point, but never added antifreeze).  Orange and green antifreeze do NOT mix well!

Here's the thermostat.

I think it's pretty safe to say, don't mix orange and green antifreeze.  Any way, it took some time to get all of that cleaned out, including removing the thermostat (so I can flush the engine side, of course - if that's still in there, it won't open, and you won't get any flush).  Then, it was time to flush the system.  I started with the radiator side, and put the ball valve host between the house garden hose connection and the top of the radiator (connected from the engine side), and the other hose at the other end, with the hose in a bucket.

Turn on the house connection, then turn on the ball value, and it pushed water through.  Initially, it came out a thick-ish (it wasn't really thick - it was still very much liquid), but definitely not transparent.

I emptied one bucket, filled the next, and the next one came out a little cleaner.

I switched sides to flush from the other direction (still on the radiator), and ran two more buckets, until I was satisfied with clear water.

I disconnected my lines from the radiator hosts and re-attached them to the engine, then took off the radiator side in order to flush the engine.  Again, I did this from both ends, flushing coolant from both directions.

Again, repeating until I had clear water.

Once I was satisfied that water was clear from both directions, I stopped.

I detached from from the hoses, put a new thermostat (95-degree) with switch and sensor threaded holes, and now I'm ready to run this engine once I get the fuel pump in.

Saturday, May 2, 2026

Hammo Can Antenna

I needed a better way to store my Hammo Can antenna.  I ran to the local hardware store and found some 1-1/2" PVC schedule 40 pipe, because that's larger than the diameter of the antenna base.  I cut a 48" length of the pipe.

Next, I glued up some fittings so that I could thread things on.  I'm going all-external to keep the 1.5" interior dimension to fit the antenna.

After that, I needed to machine some pieces that I could set inside those end caps.  I took some UHMW round, bored it through 0.800" (to clear a male PL-259 thread, which I had put a male-to-male connector in the bottom of the antenna).  I then bored out only 1.25" deep to a 1.038" inside diameter (to grip the antenna's straight knurling).  The outside was turned down to 1.480" (a slightly slip-fit for the inside of the pipe end).

With the part parted from the rest of the stock, and inserted into the pipe, I cross drilled with a 13/64" (a clearance drill size for a 5mm screw) both the outside pipe and the part itself. 

I used the hole in the part to line it up with a drill bit, clamp a vise around it to hold it securely, then drilled the part itself for a threaded insert with the size specific for my inserts (letter "J").  I installed one heat-set insert, and bolted the part in place.  Since this is my first of four holes, I added a witness mark via sharpie so that I knew which hole went where.

The part was chucked back up into the lathe to turn it round again just on that surface, and then re-installed to the pipe with one screw.  It was then taken back to the drill press to pop the three remaining holes with the 13/64" drill bit.  The same technique was used to then clamp on the 13/64", switch to the "J" drill bit, and add clearance for the inserts.  Then, install the last three inserts, and turned back down to dimension on that insert band on the lathe again.


Next up, I heated the part up until I could force the antenna/pl-259 adapter into the part, and locked it down with a washer.  Back into the pipe went the part/antenna.

This means I can unthread this from a pipe, flip it around, and thread it back into the pipe, and I have an outside antenna that can be staked to the ground via the pipe, or return it internally to the pipe to protect it in storage.
 



Now I have a functional storage for the antenna that also doubles as a riser!

Monday, April 20, 2026

You Light Up My Life

 Whoever designs Honda Accord's probably ought to find a new job.

One headlight went out.  I decided I need to just replace them all at the same time (Rock Auto had all four for the price of one light at O'Reilly's Auto).

I managed to replace two lights, on the passengers side.

  • H11LL are the low-beams for a 2017 Honda Accord.
  • 9005LL (HB3) are high beams.

The outside light on the passengers side was the easiest.  It was simply disconnecting it, rotating it to release, put the new one in and lock it in place, and re-connect the wiring.

The inside light was an irritant.  It took me a half hour to realize I could pop the plastic tank blocking it off (there is a tab on the back holding it in place, a long screw driver will release it) and lift it out of position without disconnecting any tubing.  Then it, too, because fairly easy.

The drivers side was a whole different ball of wax.  I did not want to remove the batter, so tried working around it.  I broke the new bulbs trying to do that.

Simply put, just remove the battery.  If the drivers side is one you have to swap, just remove the battery.  It will make your life a WHOLE lot easier.

Thursday, February 12, 2026

Dock(er) Wok

Docker is a simple way to create virtualization without having full systems running.  I took a moment to figure out how to run docker in relation to Postgres.

Note you need at least three servers for this for it to have any sort of redundancy. One is the "master".  You can probably get away with two (run the manager on one of the nodes), but I do not recommend this.  Really, you could get away with one server that runs the manager and two instances (I did), but know that if you don't run the docker instances on separate hosts, you've now lost all high availability, because if that server fails, the whole stack will cease to exist.

First, install Docker :

    sudo apt-get install docker.io
    sudo systemctl enable docker
    sudo systemctl start docker
    

Then, create your swarm (anything recent should have swarm built in, you just need to set it up).

    
    sudo docker swarm init --advertise-addr 192.168.x.x
    

Next, join a docker worker to the swarm

    username@server1:~$ sudo docker swarm init --advertise-addr 192.168.0.3
    Swarm initialized: current node (NODE IDENTIFIER) is now a manager.
    
    To add a worker to this swarm, run the following command:
    
        docker swarm join --token TOKEN_STRING 192.168.0.3:2377
    
    To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
    username@server1:~$
    

Then, on each worker node that isn't the manager, run the command you were provided (use sudo) :

    sudo docker swarm join --token TOKEN_STRING 192.168.1.237:2377
    

Next is your basic configuration.  Each host is relatively identical, with just a few modifications.  Each host has 3 different configuration files, pg_hba.conf, postgresql.conf, and pg_ident.conf.  Let's start by creating our directory structure.

I created a directory to house all of this so I can clear it out quickly after my learning curve.

    mkdir cluster-postgres
    cd cluster-postgres
    mkdir -p {master,slave-1,slave-2}/config
    

Next, create our three files for each host :

    touch {master,slave-1,slave-2}/config/{pg_hba.conf,postgresql.conf,pg_ident.conf}
    

With the files created, let's populate them.  Here's the gist for each host, but at the end, I'll identify differences as needed.

postgresql.conf

This file contains :

    # -----------------------------
    # PostgreSQL configuration file
    # -----------------------------
    #
    
    data_directory = '/data'
    hba_file = '/config/pg_hba.conf'
    ident_file = '/config/pg_ident.conf'
    
    port = 5432
    listen_addresses = '*'
    max_connections = 100
    shared_buffers = 128MB
    dynamic_shared_memory_type = posix
    max_wal_size = 1GB
    min_wal_size = 80MB
    log_timezone = 'Etc/UTC'
    datestyle = 'iso, mdy'
    timezone = 'Etc/UTC'
    
    #locale settings
    lc_messages = 'en_US.utf8'   # locale for system error message
    lc_monetary = 'en_US.utf8'   # locale for monetary formatting
    lc_numeric = 'en_US.utf8'    # locale for number formatting
    lc_time = 'en_US.utf8'       # locale for time formatting
    
    default_text_search_config = 'pg_catalog.english'
    
    #replication
    wal_level = replica
    wal_keep_size = 512MB  # Adjust this value as needed
    archive_mode = on
    archive_command = 'test ! -f /mnt/server/archive/%f && cp %p /mnt/server/archive/%f'
    max_wal_senders = 3
    

However, on the slave nodes, lines 28-34, which contain :

    #replication
    wal_level = replica
    wal_keep_size = 512MB  # Adjust this value as needed
    archive_mode = on
    archive_command = 'test ! -f /mnt/server/archive/%f && cp %p /mnt/server/archive/%f'
    

are removed.

pg_hba.conf

This file contains :

    # TYPE  DATABASE        USER            ADDRESS                 METHOD
    
    host     replication     replicationUser         0.0.0.0/0        md5
    
    # "local" is for Unix domain socket connections only
    local   all             all                                     trust
    # IPv4 local connections:
    host    all             all             127.0.0.1/32            trust
    # IPv6 local connections:
    host    all             all             ::1/128                 trust
    # Allow replication connections from localhost, by a user with the
    # replication privilege.
    local   replication     all                                     trust
    host    replication     all             127.0.0.1/32            trust
    host    replication     all             ::1/128                 trust
    
    host all all all scram-sha-256
    

However, on the slave nodes, line 3 :

    host     replication     replicationUser         0.0.0.0/0        md5
    

is removed.

pg_ident.conf

This file contains :

    # PostgreSQL User Name Maps
    # =========================
    #
    # Refer to the PostgreSQL documentation, chapter "Client
    # Authentication" for a complete description.  A short synopsis
    # follows.
    #
    # This file controls PostgreSQL user name mapping.  It maps external
    # user names to their corresponding PostgreSQL user names.  Records
    # are of the form:
    #
    # MAPNAME  SYSTEM-USERNAME  PG-USERNAME
    #
    # (The uppercase quantities must be replaced by actual values.)
    #
    # MAPNAME is the (otherwise freely chosen) map name that was used in
    # pg_hba.conf.  SYSTEM-USERNAME is the detected user name of the
    # client.  PG-USERNAME is the requested PostgreSQL user name.  The
    # existence of a record specifies that SYSTEM-USERNAME may connect as
    # PG-USERNAME.
    #
    # If SYSTEM-USERNAME starts with a slash (/), it will be treated as a
    # regular expression.  Optionally this can contain a capture (a
    # parenthesized subexpression).  The substring matching the capture
    # will be substituted for \1 (backslash-one) if present in
    # PG-USERNAME.
    #
    # Multiple maps may be specified in this file and used by pg_hba.conf.
    #
    # No map names are defined in the default configuration.  If all
    # system user names and PostgreSQL user names are the same, you don't
    # need anything in this file.
    #
    # This file is read on server startup and when the postmaster receives
    # a SIGHUP signal.  If you edit the file on a running system, you have
    # to SIGHUP the postmaster for the changes to take effect.  You can
    # use "pg_ctl reload" to do that.
    
    # Put your actual configuration here
    # ----------------------------------
    
    # MAPNAME       SYSTEM-USERNAME         PG-USERNAME
    

There are no differences here for each node.

Last Configurations

Create the docker "network" :

sudo docker network create postgres-cluster-network

This will print a fairly large alphanumeric ID. 

Starting the "master"

At this point in time, start the master node using the following command :

    sudo docker run -d --name postgres-master  --net postgres-cluster-network \
    -e POSTGRES_USER=postgresadmin -e POSTGRES_PASSWORD=admin123 \
    -e POSTGRES_DB=postgresdb -e PGDATA="/data" -v ${PWD}/master/pgdata:/data \
    -v ${PWD}/master/config:/config -v ${PWD}/master/archive:/mnt/server/archive \
    -p 5000:5432 postgres:latest -c 'config_file=/config/postgresql.conf'
    

If you haven't downloaded the postgres docker image yet, this will actually cause it to try and install.  If you get an error about it already existing because you tried to start it one and it gave you an error :

    docker: Error response from daemon: Conflict. The container name "/postgres-master" is already in use by container "6362ca473d3f29b7fe1bf02558f5a871fb6cf6827eff23fe01714f34cb386951". You have to remove (or rename) that container to be able to reuse that name.
    

Then list the dockers, and delete it :

    username@server1:~/postgres-cluster$ sudo docker ps -a
    CONTAINER ID   IMAGE             COMMAND                  CREATED          STATUS    PORTS     NAMES
    6362ca473d3f   postgres:latest   "docker-entrypoint.s…"   10 minutes ago   Created             postgres-master
    username@server1:~/postgres-cluster$ sudo docker rm 6362ca473d3f
    6362ca473d3f
    username@server1:~/postgres-cluster$ sudo docker ps -a
    CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
    username@server1:~/postgres-cluster$
    

Now try to start it if it had previous failed again, and it should simply give you a large alphanumeric ID again.

Next, create a replication user :

    username@server1:~$ sudo docker exec -it postgres-master bash
    [sudo] password for username:          
    root@63919c16e356:/# createuser -U postgresadmin -P -c 5 --replication replicationUser
    Enter password for new role: 
    Enter it again: 
    root@63919c16e356:/# exit
    exit
    username@server1:~$
    

Just type exit and get back to the prompt.

Now, we can move on to starting the slaves. 

Starting the slaves

For each node, run :

    username@server1:~$ sudo docker run -it --name postgres-slave1 --rm \
    > --net postgres-cluster-network \
    > -v ${PWD}/slave-1/pgdata:/data \
    > --entrypoint /bin/bash postgres:latest
    root@447571e3e11e:/#
    

This will put you in a bash prompt as "interactive", where you then run :

    pg_basebackup -h postgres-master -p 5432 -U replicationUser -D /data/ -Fp -Xs -R
    

The command initiates replication, and will use the password you specified on the master node when you started that up with the "createuser" command.

Repeat for slave-2 (of course, replacing postgres-slave1 with postgres-slave2, and the slave-1 folder names with slave-2).

Create standby instances 

Run the following :

    sudo docker run -d --name postgres-slave1 --net postgres-cluster-network \
    -e POSTGRES_USER=postgresadmin -e POSTGRES_PASSWORD=admin123 \
    -e POSTGRES_DB=postgresdb -e PGDATA="/data"  \
    -v ${PWD}/slave-2/pgdata:/data -v ${PWD}/slave-2/config:/config \
    -v ${PWD}/slave-2/archive:/mnt/server/archive -p 5002:5432 \
    postgres:latest -c 'config_file=/config/postgresql.conf'
    

Repeat for slave-2 (of course, replacing postgres-slave1 with postgres-slave2, and the slave-1 folder names with slave-2).

Test It

Connect to the master node using :

    sudo docker exec -it postgres-master bash
    

From in here, you can run your psql commands to create databases and manipulate whatever you need.

    psql --username=postgresadmin postgresdb
    

Exit, and then check the other slave nodes by connecting to them (sudo docker exec) and running psql to query any tables you've created and populated with data.

Monday, January 19, 2026

Just Pulley-ing My Leg

 A year or so back, I had heavily modified am early 1940's tiny lathe with the intent of using it to turn pens.  There are a number of small lathes that are likely cheaper than the cost put into that one, but for some odd reason, I love the old iron, and did it anyway.

The results were, less than stellar.  Indeed, it did work, but I had to take my time because the stepper motor did not have enough torque.  And that is with a small pen.

So, enter the next chapter.  Someone over on the hobby machinist forum sent an old sewing machine motor to me (think Pfaff 130, and you have the footprint of it), just for the cost of shipping.  With the pulley that was on it, I found myself in a bit of a quandary - I needed more modifications to see if this would work.  So, I set about creating a new belt tensioning system.  I patterned this variation off of a cars alternator - where it is installed and then tightened over an arc-bracket.

I made the bar using my drill press, because, I didn't have a rotary table large enough for the arc (mine is 4", the arc is 8" - but that is due to only having a scrap bar with an 8" arc that was an off cut for the 127-tooth compound gear), and also because I was too lazy to drag out the milling machine onto a cold back porch and cut it.  Really, it was because I was way too lazy.

But, it was close enough for what I needed.

So, I drilled a series of holes, then used an endill in the drill press to smooth out the arcs.  I drilled a few extra holes in some bar stock, and used a 1/4"-20 bolt to put it all together.

Next, I needed a pulley.  So I grabbed another off-cut/drop (2" diameter aluminum), chucked it up, and made a 2L pulley that was as large as this lathe could accept, but small enough to fit (1.950" with the belt).  This was faced, drilled, then reamed.  It was then turned on the boss, then the flat part of the pulley, then the angles.




The result is a usable 2L pulley to match the one on the sewing machine motor.

I wired the motor to test it, and once that checked out okay, it was time to put it all together.

This immediately feels like more torque than the stepper.  But, the proof is always in the pudding.  What is the end result?  Let's give it a try. I ran through the normal steps preparing a pen blank (this time, I chose a  gun-metal "mini" bolt action pen kit from Penn State Industries - I have a lot of family that love the regular ones, so I thought I'd give this more "unique" variant a whack.)

  1. Usually, mark the blank lengthwise to keep grain lined up.  In this case, it's small, and I was fixing another pen while I was at it, so this step didn't matter.
  2. Cut off two chunks to fit your pen tubes, about 1/4" longer than the tubes themselves.  Again, this was small enough, and I was using a scrap blank I had from another project.
  3. Drill the centering hole for the blank(s).
  4. Glue in the tubes.  Many people just use CA glue.  I have always loved 5-minute epoxy for this.
  5. Barrel-trim the ends to get it flush with the brass tubes (don't take off brass or it might not fit).
  6. Put the tubes on the appropriate bushings, then on the mandrel, and turn.
  7. Once to slightly larger than size, use the lathe to sand the blanks.  Get as high a grit as possible for the best finishes.
  8. Remove from the lathe and assemble according to the instructions. 

So, once I got to the lathe part, I grabbed a photo.

They do look fantastic (I had two on there - I was repairing another pen while I was at it).  Then, assembly prep :

Then, the final assembly.

While the motor was slightly "under-powered" (It IS a 1/5 HP sewing machine motor), it still did the trick.  I do have to take things a little slower with this, but it does mean I don't have to drag out the larger twin for this little thing.  I can simply pick it up, clamp it to a work bench, plug it in, and turn a pen. 

Sunday, January 11, 2026

Finding God in Failure

Many religions believe we are being punished for Adam's transgression.  We do not.

"And now, behold, if Adam had not transgressed he would not have fallen, but he would have remained in the garden of Eden. And all things which were created must have remained in the same state in which they were after they were created; and they must have remained forever, and had no end.

"And they would have had no children; wherefore they would have remained in a state of innocence, having no joy, for they knew no misery; doing no good, for they knew no sin.

"But behold, all things have been done in the wisdom of him who knoweth all things.

"Adam fell that men might be; and men are, that they might have joy.

"And the Messiah cometh in the fulness of time, that he may redeem the children of men from the fall. And because that they are redeemed from the fall they have become free forever, knowing good from evil; to act for themselves and not to be acted upon, save it be by the punishment of the law at the great and last day, according to the commandments which God hath given." - 2 Nephi 2:22-26

Man is fallen.  The earth is Telestial - imperfect.

It is this imperfect world that allows us to be imperfect. 

Were it not for the imperfection of the world in which we live, we could not know how to hope for a better one.  Two verses before the famous "faith" scripture in Ether, we have this little gem.

"Wherefore, whoso believeth in God might with surety hope for a better world, yea, even a place at the right hand of God, which hope cometh of faith, maketh an anchor to the souls of men, which would make them sure and steadfast, always abounding in good works, being led to glorify God." - Ether 12:4 

All of us find themselves in times of "hoping for a better world".

Sermon on the mount - after stating that we should pray for those who curse us, states :

"for he maketh his sun to rise on the evil and on the good, and sendeth rain on the just and on the unjust." - Matthew 5:45

Do I mention cancer?  Heartbreak?  Heart Failure?

By Elder Gong tells the following story. 

A Chinese story begins as a man’s son finds a beautiful horse.

“How fortunate,” the neighbors say.

“We’ll see,” says the man.

Then the son falls off the horse and is permanently injured.

“How unfortunate,” the neighbors say.

“We’ll see,” says the man.

A conscripting army comes but doesn’t take the injured son.

“How fortunate,” the neighbors say.

“We’ll see,” says the man.

This fickle world often feels tempest tossed, uncertain, sometimes fortunate, and—too often—unfortunate. Yet, in this world of tribulation, “we know that all things work together for good to them that love God.” Indeed, as we walk uprightly and remember our covenants, “all things shall work together for your good.” - Elder Gerrit W. Gong, Apr 2024, "All Things for Our Good"

Do we want to be "tempest tossed", or to wonder "why me?"  How do we avoid this?

"We become our truest, best selves only as we put off the natural man or woman and become a child before God." -  Elder Gerrit W. Gong, Apr 2024, "All Things for Our Good"

How do we put off the natural man?

For the natural man is an enemy to God, and has been from the fall of Adam, and will be, forever and ever, unless he yields to the enticings of the Holy Spirit, and putteth off the natural man and becometh a saint through the atonement of Christ the Lord, and becometh as a child, submissive, meek, humble, patient, full of love, willing to submit to all things which the Lord seeth fit to inflict upon him, even as a child doth submit to his father. - Mosiah 3:19

Yield to the enticings of the Holy Spirit.

  1. Create an environment where the world can be quiet.  Temple, at home in your personal room, or sitting in your car while waiting.
  2. Commit to follow the direction you are about to receive. 
  3. Pray for the enticing of the Spirit of God to show how you can serve someone. 
  4. Remain in the quiet environment until you feel that you have something to do.
  5. Obey immediately.
  6. Repeat.

As you put this effort in to interact with the Spirit of God and to follow through, you are becoming a saint through the atonement of Christ the Lord.  You become meek, humble, patient, full of love, and willing to submit to all things the Lord seeth fit to inflict.

You are building a trusting relationship with Christ. 

"When we trust God and His love for us, even our greatest heartbreaks can, in the end, work together for our good." -  Elder Gerrit W. Gong, Apr 2024, "All Things for Our Good"

This trusting relationship does not obliterate our enemies, or push obstacles out of our way.

Shadrach, Meshach, and Abed-nego, would not cede their covenants to a King, and were to be thrown into a furnace of fire.  When the King questioned the power of their God, their response was :

"If it be so, our God whom we serve is able to deliver us from the burning fiery furnace, and he will deliver us out of thine hand, O king.

"But if not, be it known unto thee, O king, that we will not serve thy gods, nor worship the golden image which thou hast set up." - Daniel 3:17-18 

The second half of that, "But if not", is the crucial component.  Whether something happens or not does not cause a break in what our commitment should be.

Remember, we are mortal, living in a mortal world.  But when we get lost in that "forest for the trees", pause, to also remember this.

"Even if you cannot always see that silver lining on your clouds, God can, for He is the very source of the light you seek. He does love you, and He knows your fears. He hears your prayers. He is your Heavenly Father, and surely He matches with His own the tears His children shed." - Jeff Holland, Oct 1999, "An High Priest of Good Things to Come"

God knows you, deeply and fully.  If he is "endless", pause and realize that his love for you is also endless.

"He is the light and the life of the world; yea, a light that is endless, that can never be darkened; yea, and also a life which is endless, that there can be no more death." - Mosiah 16:9

We must recognize that our timing is never God's timing.

"Some misunderstand the promises of God to mean that obedience to Him yields specific outcomes on a fixed schedule. They might think, “If I diligently serve a full-time mission, God will bless me with a happy marriage and children” or “If I refrain from doing schoolwork on the Sabbath, God will bless me with good grades” or “If I pay tithing, God will bless me with that job I’ve been wanting.” If life doesn’t fall out precisely this way or according to an expected timetable, they may feel betrayed by God. But things are not so mechanical in the divine economy. We ought not to think of God’s plan as a cosmic vending machine where we (1) select a desired blessing, (2) insert the required sum of good works, and (3) the order is promptly delivered.

"God will indeed honor His covenants and promises to each of us. We need not worry about that. The atoning power of Jesus Christ—who descended below all things and then ascended on high and who possesses all power in heaven and in earth—ensures that God can and will fulfill His promises. It is essential that we honor and obey His laws, but not every blessing predicated on obedience to law is shaped, designed, and timed according to our expectations. We do our best but must leave to Him the management of blessings, both temporal and spiritual." - Elder D. Todd Christofferson, April 2022, "Our Relationship with God"

And once you recognize this, you will also recognize that, while we are imperfect and mortal, there is a full regalia of blessings waiting.

"Some blessings come soon, some come late, and some don’t come until heaven; but for those who embrace the gospel of Jesus Christ, they come." - Jeffrey Holland - Oct 1999, "An High Priest of Good Things to Come"

Recommend Jan 2009, Thomas Monson's speech to graduating students called "Great Expectations".

Rudyard Kipling, "If" :

If you can keep your head when all about you   
    Are losing theirs and blaming it on you,   
If you can trust yourself when all men doubt you,
    But make allowance for their doubting too;   
If you can wait and not be tired by waiting,
    Or being lied about, don’t deal in lies,
Or being hated, don’t give way to hating,
    And yet don’t look too good, nor talk too wise:

If you can dream—and not make dreams your master;   
    If you can think—and not make thoughts your aim;   
If you can meet with Triumph and Disaster
    And treat those two impostors just the same;   
If you can bear to hear the truth you’ve spoken
    Twisted by knaves to make a trap for fools,
Or watch the things you gave your life to, broken,
    And stoop and build ’em up with worn-out tools:

If you can make one heap of all your winnings
    And risk it on one turn of pitch-and-toss,
And lose, and start again at your beginnings
    And never breathe a word about your loss;
If you can force your heart and nerve and sinew
    To serve your turn long after they are gone,   
And so hold on when there is nothing in you
    Except the Will which says to them: ‘Hold on!’

If you can talk with crowds and keep your virtue,   
    Or walk with Kings—nor lose the common touch,
If neither foes nor loving friends can hurt you,
    If all men count with you, but none too much;
If you can fill the unforgiving minute
    With sixty seconds’ worth of distance run,   
Yours is the Earth and everything that’s in it,   
    And—which is more—you’ll be a Man, my son! 

Elder Holland spoke about one of his own "travails" - he and his young family had car troubles.  When he drove past that spot later in life, how he wished he could tell himself :

"Don't you quit. You keep walking, you keep trying, there is help and happiness ahead. Some blessings come soon. Some come late. Some don't come until heaven. But for those who embrace the gospel of Jesus Christ, they come. It will be alright in the end. Trust God and believe in Good Things to Come." - Jeff Holland, Oct 1999, "An High Priest of Good Things to Come"

And in the same talk, he continued :

To any who may be struggling to see that light and find that hope, I say: Hold on. Keep trying. God loves you. Things will improve. Christ comes to you in His “more excellent ministry” with a future of “better promises.” He is your “high priest of good things to come.” - Jeffrey Holland - Oct 1999, "An High Priest of Good Things to Come"