Kevin Marsh is a web developer from Toledo, OH with a focus on simplicity and usability, an eye for design, and insatiable curiosity.
I have a Dokku box in my lab that runs a pile of real things: side projects, a couple of databases, and the backups for all of it. It’s my own little Heroku, and it’s been great.
What it wasn’t great at was the tiny stuff. Sometimes I want a single endpoint: a webhook receiver, a JSON blob for a shortcut on my phone, or a quick “does this idea even work” page. Spinning up a new app on a new domain with DNS and a cert felt like too much ceremony for something I might delete tomorrow. So those ideas mostly didn’t happen.
The fix turned out to be one DNS record:
I pointed *.apps.foo.tools at the Dokku server now any name under apps.foo.tools resolves to the box, and Dokku’s nginx routes each request by hostname. A wildcard only matches names that don’t have their own record, so none of the other domains on the server notice.
This is the entire app, all two files:
{}require("http").createServer((req, res) => {
res.end("hello world from test-2\n");
}).listen(process.env.PORT || 5000);No framework, no dependencies. The Node buildpack sees package.json, and with no start script, npm start falls back to node server.js.
dokku apps:create test-2
dokku domains:set test-2 test-2.apps.foo.tools
git push dokku@lab2:test-2 main
dokku letsencrypt:enable test-2About 25 seconds later, https://test-2.apps.foo.tools is live with its own Let’s Encrypt cert. When I’m done with it:
dokku apps:destroy test-2 --forceA hello world is fun, but a lot of my little ideas need to remember something. For that, SQLite is perfect: no database server, just a file.
The catch is that a Dokku container’s filesystem gets thrown away on every deploy. The database file has to live on the host and get mounted into the container:
dokku storage:ensure-directory test-3
dokku storage:mount test-3 /var/lib/dokku/data/storage/test-3:/dataThe app reads and writes /data/app.db, which is really /var/lib/dokku/data/storage/test-3/app.db on the host. That’s also a handy file to back up.
I used Bun for this one because SQLite is built in, so there’s still nothing to install. Here’s server.ts, a simple hit counter:
import { Database } from "bun:sqlite";
const db = new Database("/data/app.db", { create: true });
db.run("CREATE TABLE IF NOT EXISTS hits (at TEXT NOT NULL)");
Bun.serve({
port: process.env.PORT || 5000,
fetch() {
db.run("INSERT INTO hits VALUES (datetime('now'))");
const { n } = db.query("SELECT count(*) AS n FROM hits").get() as { n: number };
return new Response(`hello from test-3, visit #${n}\n`);
},
});And the Dockerfile:
FROM oven/bun:1-alpine
COPY server.ts /app/
CMD ["bun", "/app/server.ts"]Leaving out EXPOSE lets Dokku fall back to mapping port 80 to 5000, so there’s no ports:set step this time. After a full dokku ps:rebuild, the count picks up right where it left off. The whole thing is an 87 MB image idling at under 4 MiB of memory, which is the smallest of the bunch.
With a wildcard in place, a request for a name that isn’t an app, like typo.apps.foo.tools, still reaches the server. Without a catch-all server block, nginx hands it to whatever it considers the default site. On my box that meant nope.apps.foo.tools happily served one of my real apps, cert and all.
These are all internal toys for me, so I’ve left it alone. If that matters to you, Dokku’s docs recommend a default vhost that drops unknown hosts:
# /etc/nginx/conf.d/00-default-vhost.conf
server { listen 80 default_server; server_name _; return 444; }
server { listen 443 ssl default_server; server_name _; ssl_reject_handshake on; return 444; }In a world of complicated dev setups and toolchains, this is a nice. Almost as easy as FTPing a PHP file to an Apache server.
This post was drafted with the help of AI.
I’ve always been curious how many times certain songs are played on mainstream radio. Sometimes it seems like the same song is played every hour, or every time I get back in the car during a trip. So, a few years ago (actually, during the pandemic) I started crawling the Now Playing sections of thousands of radio websites and have started tabulating the results and building a site to explore them.
I present overplayed.app:
I can envision a ton of ways to present this data, but I started with a simple daily summary of the top songs played across the US and Canada, currently about 2,500 stations. I hope you’ll find it interesting and, if you’re curious like I am, will keep checking back for new features as I have a ton more I’d like to do with the site.
20 years ago today I posted a message on the Winamp Forums1 announcing a project I started during Christmas vacation and had been working on for a few months:

The reaction was amazing and the next few months were some of the most thrilling of my life. Some of the very same ups and downs of traffic, user growth, and server issues that dot-com era startups with millions in funding were dealing with were playing out in my bedroom, in-between classes my Senior year of high school, on AIM, and on various budget shared web hosts. I never told anyone in the real world about SongMeanings. My parents didn’t know, my school friends didn’t know. It was just my own online thing, part of my virtual persona. It even sounded weird to say “SongMeanings” out loud because I had uttered it so few times.
I let SongMeanings slip out of my hands when I start college and it was moved forward from a scappy (illegal) site to a legit business. But money was never part of the equation with me and SongMeanings. I never put any in and never got any out. But that never mattered to me.
Looking back I realized I learned more about my software career building SongMeanings than any book, class, or degree. It was just about wanting to build something, reading how others did it, copying and hacking away until it (mostly) worked, sharing it with the world, and doing it all over again.

I found an old CD-R I had burned of the code and a database backup from October 2001 and wondered if I could get it running today. To my surprise, it took about 30 minutes to get the entire site back up and running again in a Docker container, looking exactly like it did 20 years ago. Like a mosquito trapped in amber. PHP4 and MySQL 3.23 installed in fractions of a second in a debian/eol:potato Docker image on a modern 32-core machine2 and pages rendered flawlessly in a modern browser, just like they did in IE 6 on Windows 2000 at the time:

Practically everything works: viewing artists, commenting on lyrics, even logging in. I typed in my old username and password and was instantly shown a summary of what had happened since the last time I logged in, including 3 unread Private Messages. I viewed the very first lyric I posted to SongMeanings, Evaporated by Ben Folds Five:

That song still has the same ID on SongMeanings today, 1: https://songmeanings.com/songs/view/1/. So much has changed but the roots are still there, intact.
The latest blog post was me trying to sound like I had any idea about what had happened just 2 days after September 11, 20013:

The code looks like a jumbled mess to me now, as most PHP probably was in that day. SQL queries interspersed with HTML (mostly without CSS) riddled with XSS and SQL-injection vulnerabilities. Here’s a piece of PHP typical in SongMeanings 1 from probably the most run file, showsong.php (yep, every URL was a file… no RESTful routes back then!)
<? // showsong based on $id
GLOBAL $admin;
require_once("shared.inc");
db_connect();
//$qwer = mysql_query("UPDATE songs SET pageviews = pageviews + 1 WHERE id = \"$id\"");
//$ok = mysql_query($qwer);
session_start();
$qwer = mysql_query("INSERT INTO lyricviews (userid, lyric, date, ip) VALUES ('$user_identification', '$id', NOW(), '$REMOTE_ADDR')");
$ok = mysql_query($qwer);
$rs = mysql_query("SELECT lyrics.cover, lyrics.userid, date_format(dateadded, '%b %e, %Y') as submitdate, lyrics.artist_id as artistid, lyrics.title, lyrics.pageviews, lyrics.mbviews, lyrics.ratingcount as ratingcount, lyrics.rating as rating, lyrics.lyrics, lyrics.id, artists.name as artistname, users.username as username FROM songs lyrics, artists, users WHERE lyrics.id like \"$id\" and artists.id = lyrics.artist_id and users.id = lyrics.userid");
$exists = mysql_num_rows($rs);There are probably a dozen things wrong just these 12 lines of code (out of just about 14K total.) Not to mention it was all edited by hand, in real time, on the production site via FTP. No local development, no staging server, no version control, no tests, no QA: and it was amazing.
I thought about trying to get that version up somehow to browse around, but it’d likely be compromised within hours. If that’s a trip down memory lane you’d like to take, Archive.org has captured much of the journey.
There’s nothing tangible left from that era in my life. I never saw anyone I worked with on SongMeanings in real life or keep in contact with any of the friends I spent so many long nights chatting with on AOL and AIM. Once I was disconnected, they were gone.
I sometimes wonder what could’ve been, what might’ve become of SongMeanings and myself had I stuck around. But it’s a fleeting thought, what happened happened. I instead took everything I learned and built a foundation for a career 20 years running and left nothing but fond memories.
Much to my surprise, this original link on the Winamp Forums still works even after Nullsoft had been acquired and Winamp sold a couple times since! ↩︎
Of course, back in those days running real PHP was just as easy because most web hosts had it pre-installed. All you had to do was FTP up your scripts and you were up and running (well, to a point.) ↩︎
Odd how I decided to lead with news about the site instead ↩︎
2020-12-17 |
2018-07-23 |
2017-12-30 |
2017-09-07 |
2016-10-24 |
2016-06-02 |
2016-04-19 |
2016-04-18 |
2016-04-05 |
2016-04-01 |
2016-03-31 |
2016-03-29 |
2015-04-24 |
2015-04-18 |
2015-01-07 |
2015-01-02 |
2014-11-12 |
2014-11-05 |
2014-10-31 |
2014-10-27 |
2014-10-24 |
2014-10-23 |
2014-10-22 |
2014-10-20 |
2014-05-07 |
2013-08-15 |