<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xml" href="https://box464.com/feed.xslt.xml"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://box464.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://box464.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-09T22:18:10-05:00</updated><id>https://box464.com/feed.xml</id><title type="html">Jeff Sikes</title><subtitle>Jeff Sikes is a development manager for a large non-profit. Scribbles mostly about coding, leading a team and non-profit technologies.</subtitle><author><name>Jeff Sikes</name></author><entry><title type="html">The Importance of Ritual</title><link href="https://box464.com/posts/importance-of-ritual/" rel="alternate" type="text/html" title="The Importance of Ritual" /><published>2026-02-02T02:10:39-06:00</published><updated>2026-02-02T02:10:39-06:00</updated><id>https://box464.com/posts/importance-of-ritual</id><content type="html" xml:base="https://box464.com/posts/importance-of-ritual/"><![CDATA[<p>This weekend, we celebrated my parent’s 60th anniversary. My family has been talking about it for months, and we were excited to finally come together to honor them.</p>

<p>After thinking about it, I had to consider - am I doing this for myself, because I personally wanted this to be celebrated? And the answer was yes.</p>

<p>So I took a step back, and then had a discussion with my parents about what THEY want, which was really just to have another moment with family together.</p>

<p>So that’s what we did.</p>

<p>It was a small affair, per their request. Quite different from their 50th. And short - at most 2 hours, and to have small groups coming in and out rather than one large celebration.</p>

<p>As the organizer, I had to decide what makes a celebration like this important? It boils down to people wanting to express their love and respect. But in this instance, on a short schedule and tight time limit.</p>

<p>That’s when I decided to focus on the ritual aspects of anniversaries. A cake, shared memories, and a small memento of the occasion. Leave out the speeches, the long dinners, gift opening, and all the other trappings of a normal anniversary party.</p>

<p>First the cake. Obviously, with just 10-15 people it had to be small. But I wanted it to incorporate important aspects of their story.</p>

<p>My parents’ wedding was complete chaos. <a href="https://storymaps.arcgis.com/stories/0815ce88c96c4ab7baf393163a269c5f">A giant blizzard</a> roared into town just as they were about to start. The minister, my dad’s parents, and most guests got caught in the snow and didn’t make it. My parents were determined to have the wedding, and pushed forward.</p>

<p>They found a cousin that could serve as a minister (officially) that was at the church.  Luckily the cake and flowers had been delivered the day before and were ready to go.</p>

<p>After the wedding, my grandfather offered to have them stay at their house for the night. My dad said “Absolutely not” and they forged through massive snow pile-ups until they found a hotel and stayed there for one night before conceding and returning to my grandparents to wait out the storm.</p>

<p>So I made the cake snow themed, of course. The topper was their actual wedding topper from 60 years ago, which has been preserved in a glass dome over the years in our dining room cabinet.</p>

<p><img src="/assets/img/posts/anniversary-cake-topper.png" alt="Cake Topper" /></p>

<p>It would be sliced with a serving knife I bought for their 50th. And the punch would be served from a gift they received at their wedding, a crystal punch bowl. This bowl has only been used at very special occasions, like my grandparent’s 50th, my siblings’ weddings, and my parent’s own 50th and 60th. I expect these items will be used at my niece’s own weddings and maybe their own children as well.</p>

<p><img src="/assets/img/posts/anniversary-cake-bowl-napkins.jpg" alt="Snow Themed Cake" /></p>

<p>As people gathered at their scheduled time, I had a slideshow of photos for people to watch and laugh about, and a moment for each group to express their happiness to the couple before I moved them on to the cake and punch.</p>

<p>Before leaving, guests could grab a couple little mementos of the occasion - the obligatory custom cocktail napkin along with an ice scraper celebrating their 60th anniversary. This was quite a hit, by the way.</p>

<p><img src="/assets/img/posts/anniversary-napkins.png" alt="Cocktail Napkin" /></p>

<p><img src="/assets/img/posts/anniversary-ice-scraper.png" alt="Ice Scraper" /></p>

<p>All of this happened several times, in 30 minute increments for different groups.</p>

<p>And just like that original day, 60 years ago, another snow storm blew through and kept some people from attending. I was ready for that, and had Facetime calls ready to go. Again, limited timelines for each conversation. We’re on a schedule, people! Chop chop!</p>

<p>Happy, but tired, they made it through the hour and a half and a bit longer - and were able to keep the rest of their day on schedule.</p>

<p>Those moments of ritual - sharing a moment with the couple, seeing the cake and honoring physical tokens from the original event made the quick celebration a success. People felt a part of the occasion and provided a chance to celebrate and share their respect and love for the couple.</p>

<p>In the end, it’s more about their loved ones than it was about them.</p>

<p>And my parents understood that from the beginning. It took me a little longer.</p>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="personal" /><category term="family" /><category term="family" /><category term="celebration" /><category term="ritual" /><category term="reflection" /><summary type="html"><![CDATA[This weekend, we celebrated my parent’s 60th anniversary. My family has been talking about it for months, and we were excited to finally come together to honor them.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/anniversary-og-image.jpg" /><media:content medium="image" url="https://box464.com/assets/img/posts/anniversary-og-image.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Flashing Headlights on the Highway - A Fediverse Analogy</title><link href="https://box464.com/posts/fediverse-explainer/" rel="alternate" type="text/html" title="Flashing Headlights on the Highway - A Fediverse Analogy" /><published>2025-03-26T09:10:39-05:00</published><updated>2025-03-26T09:10:39-05:00</updated><id>https://box464.com/posts/fediverse-explainer</id><content type="html" xml:base="https://box464.com/posts/fediverse-explainer/"><![CDATA[<p>Have you ever been driving at night and seen an oncoming car flash their headlights to warn you about something ahead? That simple gesture illustrates the power of the fediverse better than any technical explanation.</p>

<h2 id="the-universal-signal-vs-the-closed-app">The Universal Signal vs. The Closed App</h2>

<p>When drivers flash headlights, that warning reaches everyone on the road—regardless of what car they’re driving, how much it cost, or where they are traveling from. The message is universal and travels across “platforms” (vehicles) seamlessly.</p>

<p>Compare this to navigation apps like Waze. While Waze provides detailed alerts about road hazards, those warnings only reach other Waze users. Drivers without Waze continue forward, completely unaware of the pothole, traffic issue or speed trap ahead that Waze users are actively avoiding.</p>

<h2 id="what-this-means-for-social-media">What This Means for Social Media</h2>

<p>This perfectly captures the difference between traditional social networks and the fediverse:</p>

<ul>
  <li><strong>Closed networks</strong> (Twitter, Facebook, Instagram): Your messages are trapped inside one company’s walled garden, only reaching people using that specific platform</li>
  <li><strong>The fediverse</strong>: Your messages flow freely between different platforms, reaching users wherever they are</li>
</ul>

<p>When you post on Mastodon, that message can reach users on Pixelfed, GoToSocial, Friendica, or even Threads—just like those flashed headlights reaching all drivers regardless of their vehicle.</p>

<h2 id="breaking-down-artificial-barriers">Breaking Down Artificial Barriers</h2>

<p>The beauty of the fediverse isn’t necessarily about more features—it’s about removing artificial barriers. Your thoughts, photos, and connections shouldn’t be confined to a single company’s control when they could flow naturally across the social highway.</p>

<p>Next time you’re explaining the fediverse to someone, try this analogy. Most people immediately understand why it feels unnatural for social messages to be trapped in isolated silos rather than flowing freely like real-world communication.</p>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="fediverse" /><category term="fediverse" /><category term="mastodon" /><summary type="html"><![CDATA[Have you ever been driving at night and seen an oncoming car flash their headlights to warn you about something ahead? That simple gesture illustrates the power of the fediverse better than any technical explanation.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/default_card.png" /><media:content medium="image" url="https://box464.com/assets/img/default_card.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SXSW Fediverse House - Walking Distance Food, Drinks &amp;amp; Attractions</title><link href="https://box464.com/posts/fedihouse-things-to-do/" rel="alternate" type="text/html" title="SXSW Fediverse House - Walking Distance Food, Drinks &amp;amp; Attractions" /><published>2025-03-05T02:10:39-06:00</published><updated>2025-03-05T02:10:39-06:00</updated><id>https://box464.com/posts/fedihouse-things-to-do</id><content type="html" xml:base="https://box464.com/posts/fedihouse-things-to-do/"><![CDATA[<p>When attending the inaugural <a href="https://about.flipboard.com/fediverse-house/">Fediverse House</a> at SXSW 2025 (hosted by <a href="https://about.flipboard.com/">Flipboard</a> and <a href="https://surf.social/">Surf</a>), you’re going to be based at the Speakeasy Austin (412 Congress Ave). Being just three blocks from the Austin Convention Center puts you in a prime location, but you’ll still need places to eat, drink, and unwind between sessions about decentralized social media.</p>

<p>Downtown Austin during SXSW can be overwhelming. When I mentioned I was heading there, the most common response was an appalled “WHY?!” from local Austinites. But with a little planning and some insider knowledge, you can navigate the chaos and find some hidden gems that will make your trip memorable.</p>

<p>These are my own nerdy, eclectic tastes. If you have additional recommendations or comments, add them to this Mastodon or BlueSky thread to help others.</p>

<p>This list is organized by walking distance from the venue, with my personal recommendations marked with a star (★).</p>

<table>
  <tbody>
    <tr>
      <td><strong>Legend:</strong> 🍔 Food</td>
      <td>🍹 Drinks</td>
      <td>🎭 Attraction</td>
    </tr>
  </tbody>
</table>

<h2 id="very-close-2-7-minute-walk">Very Close (2-7 minute walk)</h2>

<p><strong><a href="https://punchbowlsocial.com/location/austin-downtown-texas">Punch Bowl Social Austin</a></strong> ★<br />
<em>522 Congress Ave, Austin, TX 78701</em> | 🍔 🍹 🎭<br />
A solid choice when you want entertainment along with your food and drinks. They have bowling, arcade games, and craft cocktails in a space that manages to be lively without being overwhelming. Recommend reservations for groups.</p>

<hr />

<p><strong><a href="https://www.museumoftheweird.com/">Museum of the Weird</a></strong> <br />
<em>412 E 6th St, Austin, TX 78701</em> | 🎭<br />
When you need a break from fediverse talks and want to experience some of that famous “Keep Austin Weird” culture, this museum of oddities and sideshow relics is a perfect quick diversion. No reservations required and you can typically get through it in 30-45 minutes.</p>

<hr />

<p><strong><a href="https://www.zanzibaratx.com/">Zanzibar</a></strong> ★<br />
<em>304 E Cesar Chavez St, Austin, TX 78701</em> | 🍔 🍹<br />
If you’re seeking a more tropical escape from the conference chaos, this rooftop tiki bar offers excellent cocktails and island-inspired dishes in a lush setting. The ambiance alone makes it worth visiting. Reservations recommended, especially during SXSW.</p>

<hr />

<p><strong><a href="https://eurekarestaurantgroup.com/locations/austin/">Eureka!</a></strong><br />
<em>200 E 6th St, Austin, TX 78701</em> | 🍔 🍹<br />
When you need a substantial meal between Fediverse House sessions, their gourmet burgers alongside craft beer and whiskey selections hit the spot. Casual enough for impromptu meetups but nice enough for more formal networking.</p>

<hr />

<p><strong><a href="https://www.thejackalopebar.com/">The Jackalope</a></strong> ★
<em>404 E 6th St, Austin, TX 78701</em> | 🍔 🍹<br />
A quirky dive bar with surprisingly excellent burgers. Perfect for when you need to escape the conference pretense and just unwind in a laid-back environment. No reservations needed.</p>

<hr />

<p><strong><a href="https://mexic-artemuseum.org/">Mexic-Arte Museum</a></strong><br />
<em>419 Congress Ave, Austin, TX 78701</em> | 🎭<br />
For a cultural break, this museum of Mexican and Latin American art offers thoughtful exhibits in a conveniently located space. Can be experienced in under an hour if you’re between sessions.</p>

<hr />

<p><strong><a href="https://www.austincityguide.com/listings/congress-bridge-bats">Congress Avenue Bridge Bats</a></strong> ★<br />
<em>Congress Ave Bridge, Austin, TX 78704</em> | 🎭<br />
Get batty. Every evening around sunset (March-November), 1.5 million Mexican free-tailed bats emerge from under the Congress Avenue Bridge to feed. This spectacular natural event is completely free to watch from the bridge or nearby viewing areas. For a unique perspective, consider booking a bat-watching cruise with <a href="https://lonestarriverboat.com/bat_watching_sunset_cruises.html">Lone Star Riverboat</a>. Best viewing times in March are typically 7-8pm, but call their Bat Hotline (512-327-9355) for daily updates on emergence times.</p>

<hr />

<p><strong><a href="https://www.toyjoy.com/">Toy Joy</a></strong> ★<br />
<em>403 West 2nd St, Austin, TX 78701</em> | 🎭<br />
A quirky toy store with a similarly themed candy shop next door. It’s the perfect place to take a break from tech talks and indulge your inner child with unique toys, gifts, and novelties you won’t find anywhere else. One of my favorite places to visit if I have some money to burn.</p>

<hr />

<p><strong><a href="https://stories.whataburger.com/wmoa-at-sxsw-2025/">Whataburger Museum of Art (Pop-Up)</a></strong> ★<br />
<em>610 N. Interstate Highway 35, Austin, TX 78701</em> | 🎭<br />
A pop-up art gallery celebrating fan-created Whataburger artwork. Open <strong>March 8-9</strong> only at Wanderlust Wine Co. This uniquely Texan exhibition showcases artistic interpretations of the beloved burger chain. Perfect for some quirky social media content.</p>

<hr />

<p><strong><a href="https://www.hopecampaign.org/hope-outdoor-gallery">Castle Hill Graffiti Park (Hope Outdoor Gallery)</a></strong> ★<br />
<em>1012 Baylor St, Austin, TX 78703</em> | 🎭<br />
<a href="https://pixelfed.org/">Pixelfed</a>, <a href="https://petapixel.com/2025/02/25/bluesky-photo-app-flashes-launches-on-the-app-store/">Flashes</a> and <a href="https://vernissage.photos/home">Vernissage</a> users won’t want to miss this. Street art enthusiasts should check out this ever-changing outdoor gallery. While the original location has been relocated, the new site still features incredible murals and street art from local and international artists. The area surrounding the gallery also features plenty of street art on nearby buildings and alleys.</p>

<h2 id="short-walk-10-12-minute-walk">Short Walk (10-12 minute walk)</h2>

<p><strong><a href="https://www.kungfusaloon.com/austin/">Kung Fu Saloon</a></strong> <br />
<em>716 W 6th St, Austin, TX 78701</em> | 🍹 🎭<br />
A personal favorite for unwinding after a day of panels. This vintage arcade bar has games, good drinks, and a friendly atmosphere that’s perfect for casual networking or just blowing off steam with some Skee-Ball.</p>

<hr />

<p><strong><a href="https://www.therooseveltroomatx.com/">The Roosevelt Room</a></strong><br />
<em>307 W 5th St, Unit-B, Austin, TX 78701</em> | 🍹<br />
When you need a quiet place for a serious conversation or to impress a potential client, this cocktail bar delivers with expertly crafted drinks in an atmosphere that actually allows for conversation. Strong recommendation to make reservations.</p>

<hr />

<p><strong><a href="https://garagetx.com/">Garage</a></strong><br />
<em>503 Colorado St, Austin, TX 78701</em> | 🍹<br />
Hidden inside a parking garage (yes, really), this speakeasy offers creative cocktails and an intimate setting. Great for smaller meetings where you need to actually hear each other talk about ActivityPub implementations. I haven’t been here yet but it’s on my list for this year!</p>

<hr />

<p><strong><a href="http://midnightcowboymodeling.com/">Midnight Cowboy</a></strong><br />
<em>313 E 6th St, Austin, TX 78701</em> | 🍹<br />
If you’re planning ahead, this reservation-only speakeasy delivers an upscale cocktail experience. Book well in advance during SXSW as it fills quickly.</p>

<h2 id="quick-ride-uberscooter-5-10-minutes">Quick Ride (Uber/Scooter, 5-10 minutes)</h2>

<p><strong><a href="https://holeymoley.com/locations/austin">Holey Moley</a></strong> ★<br />
<em>807 E 4th St, Austin, TX 78702</em> | 🍔 🍹 🎭<br />
When you’re all networked out and just want something fun, this indoor mini-golf bar with themed holes, food, and cocktails is the perfect diversion. Pro tip: there’s a secret bar hidden somewhere in the building - see if you can find it! Recommended to reserve your tee time during busy periods.</p>

<hr />

<p><strong><a href="https://peasepark.org/pease-park-troll/">Pease Park &amp; The Troll Hike</a></strong> ★<br />
<em>1100 Kingsbury St, Austin, TX 78703</em> | 🎭<br />
Need some fresh air and a complete break from the decentralized social media atmosphere? Take a quick ride to see Malin, the 18-foot-tall troll sculpture hiding in Pease Park. <a href="https://trollmap.com/trollmap">These trolls</a> can be found all across the world. So far, I’ve visited five of them! It’s a surprisingly delightful art installation that gives you an excuse to get some steps in and clear your head.</p>

<hr />

<p><strong><a href="https://electricshuffleusa.com/austin/">Electric Shuffle</a></strong> <br />
<em>91 Red River St, Austin, TX 78701</em> | 🍔 🍹 🎭<br />
If you’re looking for a structured group activity that still involves drinks, this high-tech shuffleboard bar offers a great experience with interactive games that even tech industry folks will appreciate.</p>

<hr />

<p><strong><a href="https://www.cidercade.com/austin/">Cidercade Austin</a></strong> ★<br />
<em>600 E Riverside Dr, Austin, TX 78704</em> | 🍹 🎭<br />
With over 150 arcade games and craft cider on tap, this is a relaxed alternative to the typical bar scene. No reservations required, and it’s spacious enough that you can usually find a game to play without too much waiting.</p>

<hr />

<p><strong><a href="https://creativeaction.org/ca-blog/murals-of-east-austin-a-guide-to-local-public-art/">East Austin Street Art</a></strong> <br />
<em>East 6th Street between I-35 and Plaza Saltillo</em> | 🎭<br />
East Austin has become a haven for street photographers, with numerous murals and graffiti pieces scattered throughout the area. Take a short scooter ride east of I-35 and explore the neighborhood around East 6th Street. The area is constantly evolving with new artwork, but you’ll find concentrations of impressive murals along East 6th, 7th, and Cesar Chavez Streets. Don’t miss the “You’re My Butter Half” mural at 2000 E. MLK Jr. Blvd or the “Greetings from Austin” postcard mural at 1720 S 1st St.</p>

<p>Go during the day and plan for at least an hour to explore the area and take photos.</p>

<h2 id="a-few-tips-for-fediverse-house--sxsw-navigation">A Few Tips for Fediverse House &amp; SXSW Navigation</h2>

<ol>
  <li>
    <p><strong>Wear comfortable shoes</strong> - You’ll be walking much more than you expect, even between nearby venues.</p>
  </li>
  <li>
    <p><strong>Consider scooter apps or pedicabs</strong> - For destinations beyond walking distance, electric scooters or pedicabs can be faster than waiting for rideshares during peak times.</p>
  </li>
  <li>
    <p><strong>Make reservations where possible</strong> - Even for places that don’t normally require them, SXSW changes everything about Austin’s typical crowd patterns.</p>
  </li>
  <li>
    <p><strong>Stay hydrated</strong> - Austin in March can be surprisingly warm, and conference air conditioning combined with talking all day will dehydrate you quickly.</p>
  </li>
  <li>
    <p><strong>Have backup options</strong> - Even with reservations, places fill up quickly during the festival. Always have a Plan B in mind.</p>
  </li>
</ol>

<p>Looking forward to seeing many of you there and discussing the future of the fediverse!</p>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="events" /><category term="sxsw" /><category term="sxsw" /><category term="austin" /><category term="fediverse" /><category term="travel" /><summary type="html"><![CDATA[When attending the inaugural Fediverse House at SXSW 2025 (hosted by Flipboard and Surf), you’re going to be based at the Speakeasy Austin (412 Congress Ave). Being just three blocks from the Austin Convention Center puts you in a prime location, but you’ll still need places to eat, drink, and unwind between sessions about decentralized social media.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/sxsw-fediverse-house.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/sxsw-fediverse-house.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Importing Pixelfed Posts to GoToSocial with Slurp</title><link href="https://box464.com/posts/gotosocial-slurp/" rel="alternate" type="text/html" title="Importing Pixelfed Posts to GoToSocial with Slurp" /><published>2025-02-22T18:10:39-06:00</published><updated>2025-02-22T18:10:39-06:00</updated><id>https://box464.com/posts/gotosocial-slurp</id><content type="html" xml:base="https://box464.com/posts/gotosocial-slurp/"><![CDATA[<p>Recently, I wanted to migrate my Pixelfed content over to a GoToSocial instance. Enter <a href="https://github.com/VyrCossont/slurp">Slurp</a>, a new CLI tool that’s making this process much easier. Here’s my experience, including some recent updates that streamline the process.</p>

<p>First, a big thanks to the creators of <a href="https://github.com/VyrCossont/slurp">Slurp</a>, <a href="https://writer.oliphant.social/oliphant/posty-for-pixelfed-what-fresh-hell">Posty</a> and <a href="https://gotosocial.org/">GoToSocial</a> for making this possible. It’s a game-changer for anyone looking to move their content between platforms.</p>

<h1 id="the-struggles-with-post-imports">The Struggles with Post Imports</h1>

<p>Most fediverse platforms provide excellent data export packages - you can bring your followers with you, import your follows, lists, and blocked accounts to your new instance. But historically, posts have been left behind. Worse, people can still interact with your old posts and you’d never know. If you delete your account or the instance goes away completely, your posts are gone forever.</p>

<p>There have been <a href="https://github.com/JakeWharton/dumbo">other</a> <a href="https://github.com/KevinPayravi/twitter-archive-to-mastodon">attempts</a> at <a href="https://mastodoncontentmover.github.io/">post imports</a> but the complexity keeps most people away. Posts are created with current dates rather than being backdated. In some cases, the posts would appear as new, <a href="https://transitory.social/notes/9lf5969njr15a70l">spamming your followers with notifications</a>. This could overwhelm not only your instance but others as well. Other solutions required updating the core mastodon codebase, which is a non-starter for most people.</p>

<p>While platforms like Firefish/IceShrimp and Sharkey have had post importers, many instances have disabled them due to performance issues. Pixelfed has an Instagram importer, but results have been mixed and it isn’t enabled on all instances.</p>

<p>GoToSocial and Slurp manage this elegantly by allowing you to import posts with original dates that DO NOT federate on creation. Your posts get added to your timeline with original dates intact, only impacting your own server as it processes the imports.</p>

<h1 id="how-slurp-works">How Slurp Works</h1>
<p>Slurp is a CLI tool that interacts with the GoToSocial API to import posts from other platforms. It works with both Mastodon formatted archives and newer versions of the Pixelfed archive file. It requires a newer version of GoToSocial that includes <a href="https://github.com/superseriousbusiness/gotosocial/pull/3685">a patch</a> for importing backdated statuses. It’s been in some release candidates recently and is also in the <a href="https://github.com/superseriousbusiness/gotosocial/releases/tag/v0.18.0">latest beta release</a>, v0.18.0.</p>

<p>Some general guidelines to understand up front:</p>

<ul>
  <li>Backfilled statuses are not inserted into home or list timelines and do not generate notifications</li>
  <li>Only public and unlisted statuses are imported</li>
  <li>They aren’t federated on creation, but they can be federated later with boosts, likes, replies, etc.</li>
  <li>If a status mentions someone other than the author, the status will not be imported.</li>
  <li>If a status contains a custom emoji that doesn’t exist on the target instance, the status will not be imported.</li>
  <li>Is a status contains a poll, the status will not be imported.</li>
  <li>Replies, likes, boosts of the statuses are not imported.</li>
</ul>

<p>Another item I wanted to note. Posts are going to be imported as PUBLIC or UNLISTED, just as you had them in Pixelfed. GoToSocial users may have different defaults set for their own posts (followers only for example). Be aware that your posts <a href="https://gts.scoobysnack.net/@snaps">WILL show up on your public timeline</a> on your GoToSocial instance.</p>

<h1 id="my-setup">My Setup</h1>

<p>My GoToSocial instance runs from a homelab using a Portainer docker container. NGINX Proxy Manager directs traffic, and media attachments are stored in an S3 bucket rather than locally. Because Slurp uses the GoToSocial API, all of these complexities are handled automatically. It just works. If your setup is different, like you store images locally, it should still work the same.</p>

<h1 id="getting-started-with-pixelfed-export">Getting Started with Pixelfed Export</h1>

<p>Let’s walk through getting your data out of Pixelfed. There’s currently <a href="https://pixelfed.social/site/help">no official help documentation for this process</a> that I can find, so I’ll guide you through it.</p>

<p>First, navigate to Settings &gt; Export in your Pixelfed instance. For example, on pixelfed.social it’s at https://pixelfed.social/settings/home. Another way to get to it is by clicking on your photo or the “Profile” link. But not the gear icon on your profile image…</p>

<p>It’s not as easy to find as you might expect, so here’s a screenshot to help:</p>

<p><img src="/assets/img/posts/pixelfed-settings-export-location.png" alt="Finding the Export option in Pixelfed settings" /></p>

<p>You’ll be asked to enter “Sudo Mode” - this just means confirming your password to verify it’s you.</p>

<p><img src="/assets/img/posts/pixelfed-sudo-mode.png" alt="Sudo Mode confirmation dialog" /></p>

<p>Once authenticated, you’ll see the Data Export menu. While you’re here, I strongly recommend downloading each available item for safekeeping, even if you don’t plan to use them immediately.</p>

<p><img src="/assets/img/posts/pixeldfed-status-export.png" alt="Data Export options" /></p>

<p>At the very least, you need the statuses. This contains all your posts, including text, links to media, and metadata. Click the “Download” button to start the process.</p>

<blockquote>
  <p>TIP: Name your backup archives with a date, like <code class="language-plaintext highlighter-rouge">ARCHIVE-userName-pixelfed-social-YYYYMMDD.zip</code>. This is a good practice to maintain even if you’re not planning to move instances.</p>
</blockquote>

<p>A few important notes about the export:</p>
<ul>
  <li>Downloads start immediately after requesting them</li>
  <li>They’re surprisingly small because they only contain links to your media, not the media itself</li>
  <li>Slurp will handle downloading the actual media during import if necessary</li>
</ul>

<h1 id="using-slurp">Using Slurp</h1>

<p><a href="https://github.com/VyrCossont/slurp">Slurp</a> has recently added support for direct imports from Pixelfed exports. If you have an older export format, you can still use <a href="https://posty.1sland.social/pixelfed">Posty</a> to convert it to Mastodon format first.</p>

<h2 id="installation">Installation</h2>

<p>Slurp is a Go module, so you’ll need to have <a href="https://go.dev/doc/install">Go installed on your system</a> first.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>git clone https://github.com/VyrCossont/slurp.git
<span class="nb">cd </span>slurp
go mod download
go build <span class="nb">.</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Create a working directory for your exported post data - this is also where Slurp will store its mapping files and downloaded media if necessary.</p>

<h2 id="gotosocial-configuration-updates">GoToSocial Configuration Updates</h2>
<p>To reduce the risk of failure during the imports, you may want to bump up your post character and media attachment limits first in your <a href="https://docs.gotosocial.org/en/latest/configuration/#configuration-file">configuration</a>. <strong>This requires admin level rights on your GoToSocial instance.</strong></p>

<p>This is especially important if you have a lot of media in your archive - like Pixelfed exports for example!</p>

<p>These updates are not required, and you could attempt the import without changing them. If failures occur, you can just run the process again after making these changes.</p>

<p>I would highly recommend upping the status-media-max-files limit as Pixelfed posts often have more than 4 images.</p>

<p>After making the changes, restart your GoToSocial instance to apply them.</p>

<p>These are found in your <a href="https://github.com/superseriousbusiness/gotosocial/blob/main/example/config.yaml">GoToSocial config file</a>. I stupidly have them all as ENV variables in my Portainer setup, so I had to go in and change them there.</p>

<p>When using them as ENV variables, they look like this, with the <code class="language-plaintext highlighter-rouge">GTS_</code> prefix:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="c"># As seen in config file</span>
statuses-media-max-files:6

<span class="c"># As seen as an ENV variable in Portainer</span>
<span class="nv">GTS_STATUSES_MEDIA_MAX_FILES</span><span class="o">=</span>6
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Here’s what the related items look like in the config file. Update accordingly for your needs.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="c1"># Examples: [2097152, 10485760, 40MB, 40MiB]</span>
<span class="c1"># Default: 40MiB (41943040 bytes)</span>
<span class="na">media-local-max-size</span><span class="pi">:</span> <span class="s">40MiB</span>

<span class="c1"># Default emoji size limit (50KiB)</span>
<span class="na">media-emoji-local-max-size</span><span class="pi">:</span> <span class="s">50KiB</span>

<span class="c1"># Int. Maximum amount of media files that can be attached to a new status.</span>
<span class="c1"># Note that going way higher than the default might break federation.</span>
<span class="c1"># Examples: [4, 6, 10]</span>
<span class="c1"># Default: 6</span>
<span class="na">statuses-media-max-files</span><span class="pi">:</span> <span class="m">6</span>

<span class="c1"># Examples: [140, 500, 5000]</span>
<span class="c1"># Default: 5000</span>
<span class="na">statuses-max-chars</span><span class="pi">:</span> <span class="m">5000</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="emoji-migration-optional-but-recommended">Emoji Migration (Optional but Recommended)</h2>
<p>Before importing your archive, you’ll want to authenticate to your old server and copy any custom emojis you used. Posts containing custom emojis that aren’t present in your new instance will be skipped during import. Here’s how you do it.</p>

<p>This will import ALL the emoji that existed on your old instance. If there are thousands… well, you might want to consider which ones you actually used and only import those.</p>

<p>You can skip this step if you don’t use custom emojis or don’t care about them. Slurp now has a flag that I’ll include in the import command to replace emojis with text if they can’t be found on the new instance.</p>

<blockquote>
  <p>Note: Replace the example accounts below with your actual account details.</p>
</blockquote>

<p>First, authenticate to your old instance:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre># Authenticate to old instance
./slurp --user oldAccount@pixelfed.example.com auth login

# Export emojis from the pixelfed server
./slurp --user oldAccount@pixelfed.example.com emojis export --inline --file emoji.json
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--inline</code> option saves the emoji picture data along with metadata, which is useful if the old instance later disappears.</p>

<p>Then authenticate to GoToSocial <strong>as an admin user</strong> to import them.</p>

<blockquote>
  <p>If your new GtS account isn’t an admin, and you don’t have access to an admin account, you’ll need to ask your instance admin to import the emojis for you.</p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre># Authenticate to GoToSocial as admin
./slurp --user admin@gotosocial.example.com auth login

# Import emojis (requires admin rights)
./slurp --user admin@gotosocial.example.com emojis import --file emoji.json
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="importing-your-archive">Importing Your Archive</h2>
<p>Depending on the the version of Pixelfed you’re using, your statuses export may be named <code class="language-plaintext highlighter-rouge">pixelfed-statuses.json</code> or <code class="language-plaintext highlighter-rouge">outbox.json</code>. The <code class="language-plaintext highlighter-rouge">outbox.json</code> file is the older format, and you’ll need to use <a href="https://posty.1sland.social/pixelfed">Posty</a> to convert it to Mastodon format first. I’ll cover both methods below.</p>

<p>In both cases, I’ve added the <code class="language-plaintext highlighter-rouge">--allow-missing-custom-emojis</code> flag to the import command. This will replace any missing custom emojis with <code class="language-plaintext highlighter-rouge">:emoji-name:</code>. If you’ve imported ALL your utilized custom emojis, you can remove this flag.</p>

<p>If you have the newer Pixelfed export format (named pixelfed-statuses.json), you can import directly:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>./slurp --user newAccount@gotosocial.example.com archive import \
  --format pixelfed \
  --file pixelfed_export/pixelfed-statuses.json \
  --attachment-map-file pixelfed_export/attachment-map.json \
  --status-map-file pixelfed_export/status-map.json \
  --attachment-directory pixelfed_export/media_attachments \
  --allow-missing-custom-emojis
</pre></td></tr></tbody></table></code></pre></div></div>

<p>If you have an <code class="language-plaintext highlighter-rouge">outbox.json</code>, you’ll need to use Posty](https://posty.1sland.social/pixelfed) to create a Mastodon export, then use this command instead:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>./slurp --user newAccount@gotosocial.example.com archive import \
  --format mastodon \
  --file pixelfed_mastodon_export/backup \
  --status-map-file pixelfed_mastodon_export/backup/status-map.json \
  --attachment-map-file pixelfed_mastodon_export/backup/attachment-map.json \
  --allow-missing-custom-emojis
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The mapping files track progress and prevent duplicates if the import is interrupted. You can safely stop and restart the process - Slurp will skip previously imported content.</p>

<blockquote>
  <p>TIP: After starting the import, let it run. The process can take a while depending on how much media you have. Go have a cup of tea and check back later.</p>
</blockquote>

<p>And that’s it! After Slurp finishes, you can check your GoToSocial timeline to see your posts. They’ll be backdated to their original timestamps and won’t spam your followers with notifications. It’s a great way to bring your content with you when you move instances.</p>

<p><img src="/assets/img/posts/pixelfed-statuses-on-gts.png" alt="Slurp import completed with posts shown in my public GoToSocial timeline" /></p>

<h1 id="troubleshooting">Troubleshooting</h1>

<h2 id="common-import-issues">Common Import Issues</h2>
<p>A few types of posts consistently failed to import for me:</p>

<ol>
  <li>Reblogs without content: These fail with “post has no content” errors. Seemed to only occur with the Posty version of the archive, but I assume it happens with the Pixelfed version as well, just not reported the same.</li>
  <li>Empty Pixelfed entries: Some posts appear completely empty in the Posty archive and errored out.</li>
  <li>Dead account mentions: Posts mentioning unknown usernames or deactivated fediverse accounts get flagged as invalid mentions. For me, I had a few posts that mentioned the street art creator’s instagram account names with <code class="language-plaintext highlighter-rouge">@instagramName</code> that failed to import.</li>
</ol>

<p>Here’s a typical error you might see for the most common one - no text, media or poll:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>ERROR Couldn't post converted status status=752909631418482442 
err="[POST /api/v1/statuses][400] statusCreateBadRequest" 
errors="Error #01: status contains no text, media or poll"
</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="was-it-worth-it">Was it worth it?</h1>
<p>Slurp worked surprisingly well! While not every post made it over, the ones that did maintained their original timestamps, text, hashtags and media attachments perfectly. Being able to import directly from Pixelfed is a great improvement.</p>

<p>As a final triumph here, I found that <a href="https://apps.apple.com/us/app/impressia-for-pixelfed/id1663543216">Impressia, a Pixelfed iOS</a> app, was able to fetch my posts from GoToSocial without issue. This is a huge win for me, as I can now use the app to post all my street art finds. It has a beautiful interface for browsing photos and is a joy to use. Note that there are some quirks with the timelines, but I’m hoping they can be ironed out in future updates. Posting and interacting works well, and viewing my own posts is a breeze.</p>

<p>I’m thrilled that after almost 3 years in the fediverse, we finally have a tool that handles post migration exactly as needed - preserving timestamps without overwhelming federation. Looking forward to seeing how this evolves!</p>

<h1 id="references">References</h1>
<ul>
  <li><a href="https://github.com/VyrCossont/slurp">Slurp CLI Tool</a></li>
  <li><a href="https://writer.oliphant.social/oliphant/posty-for-pixelfed-what-fresh-hell">Posty for Pixelfed</a></li>
  <li><a href="https://docs.gotosocial.org/">GoToSocial Documentation</a></li>
  <li><a href="https://github.com/superseriousbusiness/gotosocial/blob/main/example/config.yaml">GoToSocial Configuration File Example</a></li>
  <li><a href="https://github.com/JakeWharton/dumbo">Dumbo Migration Tool</a></li>
  <li><a href="https://github.com/KevinPayravi/twitter-archive-to-mastodon">Twitter Archive to Mastodon</a></li>
  <li><a href="https://mastodoncontentmover.github.io/">Mastodon Content Mover</a></li>
  <li><a href="https://transitory.social/notes/9lf5969njr15a70l">Example of Import Notification Issues</a></li>
  <li><a href="https://github.com/superseriousbusiness/gotosocial/pull/3685">GoToSocial Backdated Status PR</a></li>
  <li><a href="https://github.com/superseriousbusiness/gotosocial/releases/tag/v0.18.0">GoToSocial v0.18.0 Release</a></li>
  <li><a href="https://pixelfed.social/site/help">Pixelfed Help Documentation</a></li>
  <li><a href="https://go.dev/doc/install">Go Installation Guide</a></li>
  <li><a href="https://apps.apple.com/us/app/impressia-for-pixelfed/id1663543216">Impressia for Pixelfed iOS app</a></li>
</ul>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="fediverse" /><category term="gotosocial" /><category term="pixelfed" /><category term="migration" /><category term="tutorial" /><summary type="html"><![CDATA[Recently, I wanted to migrate my Pixelfed content over to a GoToSocial instance. Enter Slurp, a new CLI tool that’s making this process much easier. Here’s my experience, including some recent updates that streamline the process.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_header_gotosocial_slurp.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_header_gotosocial_slurp.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Installing Snac - A Minimalist ActivityPub Server</title><link href="https://box464.com/posts/snac-activitypub-on-ubuntu/" rel="alternate" type="text/html" title="Installing Snac - A Minimalist ActivityPub Server" /><published>2025-02-18T12:10:39-06:00</published><updated>2025-02-18T12:10:39-06:00</updated><id>https://box464.com/posts/snac-activitypub-on-ubuntu</id><content type="html" xml:base="https://box464.com/posts/snac-activitypub-on-ubuntu/"><![CDATA[<h1 id="installing-snac-fediverse-server-on-ubuntu">Installing SNAC Fediverse Server on Ubuntu</h1>

<p>I’ve taken on some larger, complex fediverse installs like <a href="https://box464.com/posts/bonfire-setup-guide/">Bonfire</a>, and Mastodon. And some single user ones like  <a href="https://gts.scoobysnack.net">GoToSocial</a>. Heck, I even configured a <a href="https://box464.com/posts/bluesky-pds/">Bluesky PDS</a>. This time around, I’ve going even smaller. <a href="https://codeberg.org/grunfink/snac2">Snac</a> is the <a href="https://shkspr.mobi/blog/2024/02/activitypub-server-in-a-single-file/">NEXT to smallest install</a> I’ve done so far. A minimalist ActivityPub server that could probably run on just about anything.</p>

<h1 id="why-snac">Why Snac?</h1>

<p>What caught my attention was its incredibly small footprint and simple architecture. No complicated database setups, no Redis caching - just a straightforward C application that handles ActivityPub federation. It’s refreshing to see something so lightweight in the typically resource-heavy fediverse landscape.</p>

<p>One other feature I like is that by default, SNAC does NOT download all external media. That means the only media you’ll be storing is the media YOU post. This is unlike most fediverse platforms which require a ton of local storage or an S3 bucket configuration.</p>

<h1 id="system-requirements">System Requirements</h1>

<p>Remember when I said it could run on just about anything? I wasn’t kidding. Here’s what I allocated:</p>

<ul>
  <li>Memory: 1GB RAM (extreme overkill - it uses way less)</li>
  <li>CPU: 1 core (barely touched)</li>
  <li>Storage: 32GB SSD (plenty for a personal instance)</li>
  <li>OS: Ubuntu (from my Proxmox template)</li>
</ul>

<p>Compare this to Mastodon’s minimum requirements of 2GB RAM and multiple cores, and you start to see why Snac is interesting for personal instances.</p>

<h2 id="my-homelab-setup">My Homelab Setup</h2>

<p>The setup uses the following flow:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>Internet (443) -&gt; HomeLab &gt; NGINX Proxy Manager -&gt; VM (snac)
</pre></td></tr></tbody></table></code></pre></div></div>

<ul>
  <li>An Ubuntu VM (created from a Proxmox template)</li>
  <li>NGINX Proxy Manager on a SEPARATE server</li>
  <li>Domain with proper DNS configuration</li>
  <li>SSL certificate (can be obtained through Let’s Encrypt, NGINX made this easy)</li>
</ul>

<h2 id="installation-steps">Installation Steps</h2>

<h3 id="1-create-system-user">1. Create System User</h3>
<p>Create a dedicated system user for snac and make note of the password.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>useradd <span class="nt">-r</span> <span class="nt">-m</span> <span class="nt">-s</span> /bin/bash snac
<span class="nb">sudo </span>passwd snac
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="2-install-dependencies">2. Install Dependencies</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install </span>git libssl-dev libcurl4-openssl-dev build-essential
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="3-download-and-compile-snac">3. Download and Compile Snac</h3>
<p>Log in as the snac user and set up directories:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>su - snac
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/src
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/data

<span class="c"># Clone and compile</span>
<span class="nb">cd</span> ~/src
git clone https://codeberg.org/grunfink/snac2.git
<span class="nb">cd </span>snac2
make <span class="nv">LDFLAGS</span><span class="o">=</span><span class="nt">-lrt</span>
<span class="nb">sudo </span>make <span class="nb">install</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="4-initialize-snac">4. Initialize Snac</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>snac init ~/data
</pre></td></tr></tbody></table></code></pre></div></div>
<p>During initialization, you’ll need to provide:</p>
<ul>
  <li>Host name: example.domain</li>
  <li>Path prefix: (leave empty for root, defaults to /social)</li>
  <li>Network address: 127.0.0.1 (I later had to change this to 0.0.0.0)</li>
  <li>Port: 8001</li>
</ul>

<h3 id="5-configure-systemd-service">5. Configure Systemd Service</h3>
<p>Create a systemd service file:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>nano /etc/systemd/system/snac.service
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Add the following content:</p>
<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre><span class="nn">[Unit]</span><span class="w">
</span><span class="py">Description</span><span class="p">=</span><span class="s">snac ActivityPub Server</span>
<span class="py">After</span><span class="p">=</span><span class="s">network.target</span>
<span class="w">
</span><span class="nn">[Service]</span><span class="w">
</span><span class="py">Type</span><span class="p">=</span><span class="s">simple</span>
<span class="py">User</span><span class="p">=</span><span class="s">snac</span>
<span class="py">Group</span><span class="p">=</span><span class="s">snac</span>
<span class="py">ExecStart</span><span class="p">=</span><span class="s">/usr/local/bin/snac httpd /home/snac/data</span>
<span class="py">Restart</span><span class="p">=</span><span class="s">always</span>
<span class="w">
</span><span class="nn">[Install]</span><span class="w">
</span><span class="py">WantedBy</span><span class="p">=</span><span class="s">multi-user.target</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Enable and start the service:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>snac.service
<span class="nb">sudo </span>systemctl start snac.service
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="6-configure-network-access">6. Configure Network Access</h3>
<p>Edit the server configuration to allow external access:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>vim ~/data/server.json
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I had to update the address field so the site was accessible to my NGINX Proxy Manager, since that runs on a different server within my homelab. Switched it from 127.0.0.1 to 0.0.0.0 which allows external communication out on all ports.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="p">{</span><span class="w">
    </span><span class="nl">"address"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.0.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="err">...</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="7-configure-nginx-proxy-manager">7. Configure NGINX Proxy Manager</h3>

<p>Create a new proxy host with the following locations. Let me tell you this was annoying to handle via NGINX Proxy Manager! Rather than a single file I could add these all to, I had to point and click and add each location separately. It worked, tho. And probably in the end the UI is just creating that same file behind the scenes…. Double check your work because this is easy to mess up and difficult to troubleshoot.</p>

<p>Repeat, repeat, repeat. This is just the first 1st locations show in NGINX PM as example - look below for all 7.</p>

<p><img src="/assets/img/posts/snac_nginx_locations.png" alt="The NGINX Proxy Manager Location Configuration modal" /></p>

<ol>
  <li>Root Location:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>WebFinger:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /.well-known/webfinger
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>Mastodon API v1:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /api/v1
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>Mastodon API v2:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /api/v2
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>OAuth:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /oauth
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>NodeInfo:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /.well-known/nodeinfo
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>Host Meta:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /.well-known/host-meta
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>Share:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /share
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>Authorize Interaction:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Location: /authorize_interaction
Forward: http://VM_IP:8001
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
</ol>

<p>For each location, add the following custom configuration:</p>
<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="k">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$http_host</span><span class="p">;</span>
<span class="k">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$remote_addr</span><span class="p">;</span>
<span class="k">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="8-create-user-account">8. Create User Account</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>snac adduser ~/data
</pre></td></tr></tbody></table></code></pre></div></div>
<p>Follow the prompts to create your user account.</p>

<h3 id="9-configure-instance-settings">9. Configure Instance Settings</h3>
<p>Edit server.json to set instance details:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="p">{</span><span class="w">
    </span><span class="nl">"admin_email"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-email@domain.com"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"admin_account"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-username"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Your Instance Title"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"short_description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Your instance description"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="youre-up">You’re up!</h2>
<p>At this point you can check to see if your snac instance is working. You should see something like this.</p>

<p><img src="/assets/img/posts/snac_nomnom_home.png" alt="Default SNAC instance homepage" /></p>

<p>Note the user listed - click on it and you’ll see the PUBLIC page for this user that everyone can see.</p>

<p>On the public page, you can see a couple of URLs “RSS” and “Private”.</p>

<p>Private sends you to a page that allows you to post, follow, all kinds of fediverse things. You’ll need your username and password to get there.</p>

<p><img src="/assets/img/posts/snac_grunfik_public.png" alt="Default SNAC public user page" /></p>

<h2 id="compatibilty-with-the-mastodon-api">Compatibilty with the Mastodon API</h2>
<p>Snac is compatible with the Mastodon API, which means you can use Mastodon clients to interact with your instance. This is a huge plus, as it opens up a wide range of clients and apps that can be used with your instance. I’ve tried Phanpy, FediText, Ivory - they all work great!</p>

<h2 id="customization">Customization</h2>

<p>You can customize your instance with your own css styling, or <a href="https://codeberg.org/grunfink/snac2#incredibly-awesome-css-themes-for-snac">use an existing theme</a>.</p>

<h3 id="styling">Styling</h3>
<ol>
  <li>Global styling (public pages):
    <ul>
      <li>Edit <code class="language-plaintext highlighter-rouge">/home/snac/data/style.css</code></li>
      <li>Note: The homepage (served from greeting.html) does not include styling by default</li>
      <li>The style.css file only affects profile pages, timelines, and other ActivityPub-related pages</li>
      <li>If you want to style the homepage, you’ll need to add CSS directly in greeting.html</li>
    </ul>
  </li>
  <li>User-specific styling (private pages):
    <ul>
      <li>Edit <code class="language-plaintext highlighter-rouge">/home/snac/data/user/username/static/style.css</code></li>
      <li>This affects the logged-in view for that specific user</li>
    </ul>
  </li>
</ol>

<p>I customized my public and private page styles like this. All the same functionality, just themed.</p>

<p><img src="/assets/img/posts/snac_nomnom_public.png" alt="Customized SNAC public user page" /></p>

<h3 id="emojis">Emojis</h3>
<p>Yes! You can have custom emojis. I have not delved into this yet, but here’s the documentation.</p>

<p><a href="https://comam.es/snac-doc/snac.8.html#Custom_Emojis">Snac Custom Emoji Configuration</a></p>

<h2 id="troubleshooting">Troubleshooting</h2>

<h3 id="file-permissions">File Permissions</h3>
<p>Ensure correct permissions:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">sudo chown</span> <span class="nt">-R</span> snac:snac /home/snac/data
<span class="nb">sudo chmod </span>660 /home/snac/data/style.css
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="service-issues">Service Issues</h3>
<p>Check service status:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>systemctl status snac.service
<span class="nb">sudo </span>journalctl <span class="nt">-u</span> snac.service
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="cache-issues">Cache Issues</h3>
<ol>
  <li>Add cache-control headers in NGINX</li>
  <li>Perform hard refresh in browser</li>
</ol>

<h2 id="maintenance">Maintenance</h2>

<h3 id="updating-snac">Updating Snac</h3>

<p>I have not had to do this yet, but this would be the steps to follow to update snac after a new version is available…I think.</p>

<blockquote>
  <p>Careful, I didn’t run these commands…for future reference!</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="nb">cd</span> ~/src/snac2
git pull
make <span class="nv">LDFLAGS</span><span class="o">=</span><span class="nt">-lrt</span>
<span class="nb">sudo </span>make <span class="nb">install
sudo </span>systemctl restart snac.service
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="backups">Backups</h3>
<p>Remember that snac uses <a href="https://duckduckgo.com/?q=what+are+hard+links&amp;iax=videos&amp;ia=videos&amp;iai=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DlW_V8oFxQgA">hard links</a>, so when backing up make sure to use these extra flags to follow the hard links (-H) and preserve them during the sync (-H).</p>

<p>I still don’t understand all the specifics of hard links, just know that without these flags, you’ll be backing up duplicate data and may break snac’s internal file organization and fail when you try to restore from it later.</p>

<blockquote>
  <p>If you see something wrong with these scripts, please let me know!</p>
</blockquote>

<p>Since I’m running this on ProxMox and I take a full backup of the server nightly…I admit I’m not too concerned about data loss, mostly because this is just a hobby instance.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="c"># Using tar</span>
<span class="nb">tar</span> <span class="nt">-czf</span> backup.tar.gz <span class="nt">-H</span> /home/snac/data

<span class="c"># Using rsync</span>
rsync <span class="nt">-avH</span> /home/snac/data /backup/location
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="resources">Resources</h2>
<h3 id="official-documentation">Official Documentation</h3>
<ul>
  <li><a href="https://codeberg.org/grunfink/snac2">Snac Source Code</a> - The main repository</li>
  <li><a href="https://comam.es/snac-doc/">Online Snac Manuals</a> - User, administrator, and data formats documentation</li>
  <li><a href="https://comam.es/snac-doc/snac.8.html#Custom_Emojis">Snac Custom Emoji Configuration</a> - How to set up custom emojis</li>
</ul>

<h3 id="themes">Themes</h3>
<ul>
  <li><a href="https://codeberg.org/voron/snac-style">Collection of Snac Themes</a> - by Ворон</li>
</ul>

<h3 id="installation-guides">Installation Guides</h3>
<ul>
  <li><a href="https://gyptazy.com/blog/install-snac2-on-freebsd-an-activitypub-instance-for-the-fediverse/">Install Snac on FreeBSD</a> - by gyptazy</li>
  <li><a href="https://encrypted.tesio.it/2024/12/18/how-to-run-your-own-social-network.html">Run Your Own Social Network</a> - by Giacomo Tesio</li>
</ul>

<h3 id="further-reading">Further Reading</h3>
<ul>
  <li><a href="https://duckduckgo.com/?q=what+are+hard+links">Understanding Hard Links</a> - Because I still don’t fully understand them 😅</li>
  <li><a href="https://www.w3.org/TR/activitypub/">ActivityPub Protocol</a> - The standard that makes federation possible</li>
</ul>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="fediverse" /><category term="snac" /><summary type="html"><![CDATA[Installing SNAC Fediverse Server on Ubuntu]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_header_snac.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_header_snac.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Installing Hollo with Docker - A Complete Guide</title><link href="https://box464.com/posts/hollo-install/" rel="alternate" type="text/html" title="Installing Hollo with Docker - A Complete Guide" /><published>2025-02-08T05:11:45-06:00</published><updated>2025-02-08T05:11:45-06:00</updated><id>https://box464.com/posts/hollo-install</id><content type="html" xml:base="https://box464.com/posts/hollo-install/"><![CDATA[<h1 id="installing-hollo-with-docker-a-complete-guide">Installing Hollo with Docker: A Complete Guide</h1>

<p>This guide covers setting up <a href="https://hollo.social">Hollo</a>, an ActivityPub server, using Docker in a homelab environment with S3 Storage and NGINX Proxy Manager.</p>

<p>Hollo leans toward single user instances, but can be configured for more than one person. This guide focuses on a single user instance with external media storage and a PostGres database stored outside the docker container.</p>

<p>Note that there is a much easier route to try out Hollo! You can get up and running with just <a href="https://docs.hollo.social/install/railway/">a single click using Railway</a>. But of course I wanted to do it the hard way. 😄</p>

<h2 id="environment-overview">Environment Overview</h2>

<p>Here is my configuration. External storage isn’t necessary, but it saves space and keeps things tidy. By default, Hollo creates a local minio server for media storage. My composer file removed that and uses external storage instead.</p>

<ul>
  <li>Host: Homelab server</li>
  <li>Container Management: Portainer</li>
  <li>Reverse Proxy: NGINX Proxy Manager</li>
  <li>Postgres Database: Stored on separate volume for easy backups</li>
  <li>DNS: Netlify, Cloudflare, Porkbun - up to you!</li>
  <li>CDN/Storage: External - you can use any S3-compatible storage, Hollo configures a local minio instance by default</li>
  <li>Domain Setup:
    <ul>
      <li>Main site: your-instance.example.com</li>
      <li>Media: media.your-instance.example.com</li>
    </ul>
  </li>
</ul>

<h2 id="prerequisites">Prerequisites</h2>

<ul>
  <li>Docker and Portainer installed</li>
  <li>NGINX Proxy Manager configured</li>
  <li>S3 bucket set up with your public and private keys and endpoint url</li>
  <li>Domain or sub-domain configured with your DNS provider</li>
</ul>

<blockquote>
  <p>Domains are used for federation, and you should be warned that once you setup federation on that domain or sub-domain, it can’t be changed, and you can’t use it for another instance. Ever. So choose wisely!
If you decided you want to change from Hollo to IceShrimp, you would need to use a different domain, otherwise you’re going to be getting a ton of traffic for the wrong platform.</p>
</blockquote>

<h2 id="storage-and-backup-strategy">Storage and Backup Strategy</h2>

<h3 id="database-storage-location">Database Storage Location</h3>

<p>The PostgreSQL data is stored in a custom location (e.g., <code class="language-plaintext highlighter-rouge">/path/to/docker-volumes/hollo/data</code>) instead of using Docker’s built-in volume management. I chose this option because it supports integration with an existing Borg backup solution I have in place following this excellent guide from GoToSocial: <a href="https://docs.gotosocial.org/en/latest/admin/backup_and_restore/">Backing Up Your GoToSocial Instance</a>.</p>

<p>You could completely skip this and use Docker volumes, but I like the flexibility of managing the data backups.</p>

<ol>
  <li>Your chosen backup directory can be included in regular backup routines</li>
  <li>Database files are automatically included in system backups</li>
  <li>BorgBase has <a href="https://docs.borgbase.com/setup/borg/databases">scripts ready to go</a> for backing up PostgreSQL data</li>
  <li>Data is easily accessible for backup verification or manual management</li>
  <li>No need for separate database backup scripts</li>
</ol>

<h2 id="docker-compose-configuration">Docker Compose Configuration</h2>

<p>I wandered off the beaten path a bit with my configuration. I wanted to use a custom location for the PostgreSQL data directory and external storage for media. In addition, I moved my secrets and other custom values into environment variables.</p>

<p>I also added a healthcheck, which is the first time I’ve done this on my own in a Docker Compose file. I was running against an issue where the Hollo container would start before the Postgres container was ready. This <a href="https://docs.docker.com/compose/how-tos/startup-order/">healthcheck and related “depends_on” section</a> fixed that issue.</p>

<blockquote>
  <p>This composer file is based off <a href="https://docs.hollo.social/install/docker/">official Hollo composer file</a>, but it has been modified to suit my needs. Please refer to the official documentation before implementing this in your environment as it may have been updated since this post.</p>
</blockquote>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
</pre></td><td class="rouge-code"><pre><span class="na">services</span><span class="pi">:</span>
  <span class="na">hollo</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/fedify-dev/hollo:canary</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3131:3000"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">DATABASE_URL</span><span class="pi">:</span> <span class="s">postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}</span>
      <span class="na">SECRET_KEY</span><span class="pi">:</span> <span class="s">${SECRET_KEY}</span>
      <span class="na">LOG_LEVEL</span><span class="pi">:</span> <span class="s">${LOG_LEVEL}</span>
      <span class="na">BEHIND_PROXY</span><span class="pi">:</span> <span class="s">${BEHIND_PROXY}</span>
      <span class="na">DRIVE_DISK</span><span class="pi">:</span> <span class="s">s3</span>
      <span class="na">ASSET_URL_BASE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://media.your-instance.example.com/"</span>
      <span class="na">S3_REGION</span><span class="pi">:</span> <span class="s">us-east-1</span>
      <span class="na">S3_BUCKET</span><span class="pi">:</span> <span class="s">your-bucket-name</span>
      <span class="na">S3_ENDPOINT_URL</span><span class="pi">:</span> <span class="s">your-r2-endpoint.r2.cloudflarestorage.com</span>
      <span class="na">S3_FORCE_PATH_STYLE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">AWS_ACCESS_KEY_ID</span><span class="pi">:</span> <span class="s">${AWS_ACCESS_KEY_ID}</span>
      <span class="na">AWS_SECRET_ACCESS_KEY</span><span class="pi">:</span> <span class="s">${AWS_SECRET_ACCESS_KEY}</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">postgres</span><span class="pi">:</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">service_healthy</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:17</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">POSTGRES_USER</span><span class="pi">:</span> <span class="s">${POSTGRES_USER}</span>
      <span class="na">POSTGRES_PASSWORD</span><span class="pi">:</span> <span class="s">${POSTGRES_PASSWORD}</span>
      <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">${POSTGRES_DB}</span>
      <span class="na">PGDATA</span><span class="pi">:</span> <span class="s">/var/lib/postgresql/data/pgdata</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/path/to/docker-volumes/hollo/data:/var/lib/postgresql/data</span>
    <span class="na">healthcheck</span><span class="pi">:</span>
      <span class="na">test</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">CMD-SHELL"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">pg_isready</span><span class="nv"> </span><span class="s">-U</span><span class="nv"> </span><span class="s">${POSTGRES_USER}</span><span class="nv"> </span><span class="s">-d</span><span class="nv"> </span><span class="s">${POSTGRES_DB}"</span><span class="pi">]</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">10s</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="s">5s</span>
      <span class="na">retries</span><span class="pi">:</span> <span class="m">10</span>
      <span class="na">start_period</span><span class="pi">:</span> <span class="s">30s</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="installation-steps">Installation Steps</h2>

<p>If you are not storing your PostgreSQL data in a custom location, you can skip the first step. I found permissions to be a bit tricky with LXD and Docker, so I’ve included a solution that worked for me.</p>

<ol>
  <li>
    <p><strong>Prepare PostgreSQL Data Directory</strong></p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="nb">cd</span> /path/to/docker-volumes/hollo
<span class="nb">sudo mkdir </span>data
<span class="nb">sudo chown </span>lxd:docker data
<span class="nb">sudo chmod </span>770 data
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>
    <p><strong>Configure Environment Variables in Portainer</strong></p>
  </li>
</ol>

<p>I moved all the secrets and custom values into environment variables in Portainer. This is a more secure way to manage secrets and allows for easy changes in the future.</p>

<blockquote>
  <p>SECRET_KEY is used to sign federation requests. It should be kept secure and not changed once federation is working. Don’t lose it!</p>
</blockquote>

<p>You can generate the SECRET_KEY and POSTGRES_PASSWORD using openssl:</p>

<p><code class="language-plaintext highlighter-rouge">openssl rand -base64 32</code></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>POSTGRES_USER=hollo_user
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=hollo
AWS_ACCESS_KEY_ID=your_r2_access_key
AWS_SECRET_ACCESS_KEY=your_r2_secret_key
SECRET_KEY=your_secure_random_key
LOG_LEVEL=info
BEHIND_PROXY=true
</pre></td></tr></tbody></table></code></pre></div></div>

<ol>
  <li><strong>Configure DNS and NGINX Proxy Manager</strong></li>
</ol>

<p>The only tricky thing here is I had to use a Porkbun DNS challenge <a href="https://porkbun.com/api/json/v3/documentation">using their API</a> for the SSL certificate, which isn’t well documented. I had to get an API Key and Secret from Porkbun and add them into the “Credentials File Content” field of the Let’s Encrypt DNS challenge prompt within NGINX Proxy Manager.</p>

<ul>
  <li>DNS Provider: Create new A record for your-instance.example.com pointing to your server’s IP address</li>
  <li>NGINX: Create new proxy host
    <ul>
      <li>Domain: your-instance.example.com</li>
      <li>Scheme: http</li>
      <li>Forward to: http://your-docker-host:3131</li>
      <li>SSL: Let’s Encrypt with a DNS challenge</li>
    </ul>
  </li>
</ul>

<h2 id="common-issues-and-solutions">Common Issues and Solutions</h2>

<h3 id="1-port-configuration">1. Port Configuration</h3>

<p><strong>Issue</strong>: Application not accessible
<strong>Solution</strong>: Hollo must run on internal port 3000, but can be mapped to any external port. However, I had it set to 3131:3131 in my docker compose file initially.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="na">ports</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">3131:3000"</span> <span class="c1"># External:Internal</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="2-its-always-dns">2. It’s always DNS</h3>

<p><strong>Issue</strong>: Browser showing redirect loops or SSL errors
<strong>Solution</strong>:</p>

<ul>
  <li>Ensure your DNS / SSL configuration is correct (that is…a broad statement lol)</li>
  <li>Keep NGINX PM scheme as http for internal communication</li>
  <li>Set BEHIND_PROXY=true in Hollo configuration</li>
</ul>

<p>My personal DNS issue was ensuring the NGINX Proxy Manager certificate and “Require SSL” setting were correct. Porkbun DNS challenge was a bit tricky, but once I got the API key and secret in NGINX PM, it worked like a charm.</p>

<h2 id="additional-notes">Additional Notes</h2>

<ul>
  <li>Keep your SECRET_KEY backed up and don’t change it once federation is working</li>
  <li>Monitor PostgreSQL logs for any database issues</li>
  <li>Regular backups of the PostgreSQL data directory are recommended</li>
</ul>

<h2 id="finishing-configuration">Finishing Configuration</h2>

<p>Access https://your-instance.example.com/setup to complete the initial configuration of your Hollo instance.</p>

<p>Follow Hollo’s <a href="https://docs.hollo.social/install/setup/">official documentation to complete the configuration</a>.</p>

<blockquote>
  <p>When I filled out the initial account creation form, it took a bit longer than I expected for a response to come back, but it finally did. So be patient if it takes a bit longer than you expect.</p>
</blockquote>

<p>You probably want to upload a profile picture and a header image. I might be wrong about this, but it appears you can only do that from a client app. I used <a href="https://fedicat.com/">Fedicat</a>, but there are tons to choose from that <a href="https://docs.hollo.social/clients/">work with Hollo</a>.</p>

<p>Now you’re ready to start hollo-ing! 🎉</p>

<p>Follow me on my Hollo instance: <a href="https://hollo.box464.social/@hollo464">@hollo464@hollo.box464.social</a></p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>When I first pulled up the Hollo setup page, it felt very familiar. It’s clean and simple, with large UI controls. I then realized it’s built with <a href="https://picocss.com/">PicoCss</a>, which is the same CSS framework I used for my little mastodon API playtoy, <a href="https://devoton.box464.com">Devtodon</a>. I love the simplicity and ease of use of PicoCss and glad to know others do, too!</p>

<blockquote>
  <p>Need to figure out why Hollo isn’t working with Devtodon. 🤔</p>
</blockquote>

<p>Hollo is a great project and I’m excited to see where it goes. I’m happy to have it running in my homelab and look forward to seeing how it evolves. I hope this guide helps you get started with Hollo in your own environment.</p>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://docs.hollo.social/">Hollo Official Documentation</a></li>
  <li><a href="https://github.com/fedify-dev/hollo">Hollo GitHub Repository</a></li>
  <li><a href="https://picocss.com/">PicoCss</a></li>
  <li><a href="https://docs.gotosocial.org/en/latest/admin/backup_and_restore/">Backing Up Your GoToSocial Instance</a></li>
  <li><a href="https://docs.docker.com/compose/compose-file/compose-file-v3/#healthcheck">Docker Compose Healthcheck</a></li>
</ul>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="fediverse" /><category term="hollo" /><summary type="html"><![CDATA[Installing Hollo with Docker: A Complete Guide]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_header_hollo.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_header_hollo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Making the most of my newbie Dreamforce experience</title><link href="https://box464.com/posts/dreamforce-wrapup/" rel="alternate" type="text/html" title="Making the most of my newbie Dreamforce experience" /><published>2024-09-21T06:11:45-05:00</published><updated>2024-09-21T06:11:45-05:00</updated><id>https://box464.com/posts/dreamforce-wrapup</id><content type="html" xml:base="https://box464.com/posts/dreamforce-wrapup/"><![CDATA[<h1 id="a-first-timers-guide-to-navigating-dreamforce-2024">A First-Timer’s Guide to Navigating Dreamforce 2024</h1>

<p>Attending Dreamforce for the first time felt a bit like visiting Disney World—there’s so much to see, do, and experience that it’s impossible to fit everything into one visit. Dreamforce 2024 was a whirlwind of learning and exploration, and I came away with valuable lessons and a plan for what to prioritize next time. Here are my key takeaways.</p>

<h2 id="packing-essentials">Packing Essentials</h2>

<p>Business casual and beyond are the norm at Dreamforce. For days when I knew I was going to be networking, I did dress up more than I normally would. But for the days when I was just going to be in sessions, I dressed more casually. To each their own.</p>

<p>Layering is key for staying comfortable throughout the day. Bring a light jacket and be prepared for pop-up showers, as you’ll be walking between venues often. I used a <a href="https://amzn.to/3BaEsfx">packable rain jacket</a> that fit easily in my backpack. Some of the rooms are cold so you might use it indoors as well.</p>

<p><a href="https://amzn.to/4gBOjeC">Buy a new comfortable pair of shoes</a> just for this occasion. Bring a <a href="https://amzn.to/3TE202v">small backpack</a> that will fit your laptop, snacks, and room for swag. Make sure to check for size restrictions on bags, though I didn’t see anyone get turned away.</p>

<p>A <a href="https://amzn.to/4ecREPr">portable charger</a> is a must-have for long days at the event. I also recommend bringing a reusable water bottle to stay hydrated throughout the day.</p>

<p><a href="https://amzn.to/3MSwEkX">Noise cancelling headphones</a> were helpful for several reasons, the main one being the app has a feature to listen to sessions live.</p>

<p>Business cards do make an impression. I didn’t have any, but for those that handed them out, I now physically have a card in hand for that interaction.</p>

<h2 id="navigating-the-dreamforce-map">Navigating the Dreamforce Map</h2>

<p>Before arriving at Dreamforce, it helps to <a href="https://pbs.twimg.com/media/Dnwzh-HU8AEimn8.jpg">get familiar with the key locations</a> you’ll encounter. Salesforce uses their own forest themed terminology for common conference concepts which can be confusing to first timers. Each area has a unique vibe and purpose:</p>

<h3 id="trailblazer-forest">Trailblazer Forest</h3>

<p>A public exhibit hall that includes short sessions (often 20 minutes or less) and hands-on experiences. It can get noisy, so I used noise-cancelling headphones to concentrate during hands-on activities. This area had some of the best sessions I attended—don’t miss it if you’re interested in developer or admin tracks. You can also listen to sessions in the mobile app if it’s too loud.</p>

<p>This was on Level 2 of Moscone West this year.</p>

<h3 id="lodge-village">Lodge Village</h3>

<p>A prime spot for Salesforce product demonstrations. Many of the quests required visiting this area to get demos or learn more about Salesforce features. These areas are in the lobbies between session rooms, so they’re easy to visit between sessions.</p>

<p>This was located on Level 1 of Moscone West in 2024.</p>

<h3 id="dreamforest">Dreamforest</h3>

<p>An outdoor walkway that connects the main keynote area to the exhibit hall. It’s lined with activities, vendors, music, food, and drinks at different times of the day, making it a fun place to recharge between venues. It’s also highly trafficked.</p>

<p>This was located along Howard Street, but with all the greenery and booths, it felt like walking through a lush forest path!</p>

<p><img src="/assets/img/posts/dreamforce-forest.png" alt="Dreamforest Entrance" /></p>

<h3 id="campground">Campground</h3>

<p>The main exhibit hall where vendors display their products and Salesforce demos take place. You’ll find food and drinks here at various times, and several quests will take you through this area. Some vendors also run their own quests with limited prizes, so find out about them early.</p>

<p>Take the Dreamforest path to reach the Campground, which is located down the escalators in Moscone North.</p>

<h2 id="navigating-session-types">Navigating Session Types</h2>

<p>Dreamforce offers a wide variety of session formats, each with its own value depending on what you’re looking to get out of the event:</p>

<h3 id="hands-on-workshops">Hands-on Workshops</h3>

<p>If you’re looking for technical deep dives, these are a must. They offer practical, real-world training with Salesforce features. Some workshops provide computers, but be prepared to bring your own laptop for others. Hands-on workshops are ideal for admins and developers wanting to gain hands-on experience while tackling complex problems in real-time.</p>

<h3 id="roundtables--circles-of-success">Roundtables &amp; Circles of Success</h3>

<p>These small-group discussions are invaluable for learning how others in the Salesforce ecosystem are solving real challenges. Beyond learning, they offer great networking opportunities. If you prefer structured networking to happy hours, these sessions are an ideal way to meet peers and exchange ideas.</p>

<h3 id="keynotes">Keynotes</h3>

<p>Keynotes are exciting but time-consuming. You’ll need to arrive at least 45 minutes early to secure a seat, which means losing about 2-3 hours per keynote when factoring in the waiting time. The good news is that all keynotes are available on demand, so you don’t have to stress about missing them live.</p>

<h2 id="do-the-quests-and-collect-the-swag">Do the Quests and Collect the Swag</h2>

<p>Feeling overwhelmed by all the activities? The quests give you direction and guide you through various areas while teaching you about Salesforce products. Plus, completing quests earns you rewards like pins, hoodies, and more.</p>

<p>Pro tip: Some quests start even before the event begins, so watch for notifications and emails. During the event, you can track your progress using the mobile app. Tackle popular quests early, as supplies for some prizes tend to run out quickly.</p>

<p>Additionally, don’t hesitate to ask other attendees about their swag—sometimes they’ve discovered quests that aren’t as widely known.</p>

<p><img src="/assets/img/posts/dreamforce-swag.png" alt="Swag Haul" /></p>

<h2 id="food-and-drink-availability">Food and Drink Availability</h2>

<p>Dreamforce did an excellent job of providing free meals throughout the day. You can easily get by with free breakfast, snacks, and lunch, which allows you to save your meal stipend for a nice dinner. Various locations like the <strong>Trailblazer Forest</strong>, <strong>Dreamforest</strong>, and <strong>Campground</strong> offer snacks and drinks, and networking breakfasts are a great opportunity to fuel up and make connections.</p>

<p>If the main lunch areas feel too crowded, head to one of the nearby hotels. These areas are often quieter but still provide plenty of food options.</p>

<h2 id="virtual-hands-on-continue-learning-after-dreamforce">Virtual Hands-On: Continue Learning After Dreamforce</h2>

<p>Even after Dreamforce ends, you can continue learning through <strong>Salesforce+</strong>. This year, virtual hands-on sessions will run from September 24-26, offering three in-depth workshops presented twice a day. Each session lasts two hours and covers specific topics:</p>

<ul>
  <li><strong>Day 1:</strong> Data Cloud</li>
  <li><strong>Day 2:</strong> Prompt Builder</li>
  <li><strong>Day 3:</strong> AgentForce</li>
</ul>

<p>These sessions aren’t on-demand; they’re live, giving you a chance to interact and get real-time feedback as you work through exercises. If you’re looking to continue building your technical skills after the event, this is a great opportunity to dive deeper.</p>

<h2 id="finding-quiet-moments">Finding Quiet Moments</h2>

<p>Dreamforce can get overwhelming with the crowds and noise, but there are ways to find moments of peace. I used <a href="https://amzn.to/3MSwEkX">noise-cancelling headphones</a> to block out the background chatter in busier areas. You can also find low-traffic areas or designated quiet rooms if you need to take a break and rest your ears. The event app is helpful for locating these quieter spots, so you can recharge before diving back into the action.</p>

<h2 id="what-ill-prioritize-next-time">What I’ll Prioritize Next Time</h2>

<p>Next time, I’ll make more time for networking and Braindates. Although I attended a few sessions, I feel I could’ve connected with more people if I had prioritized these opportunities. I also plan to take advantage of Salesforce certifications, which are offered at no extra cost during Dreamforce. Preparing for a certification might take time, but it’s worth considering for a second or third visit.</p>

<p>Additionally, there are community get togethers that I completely missed out on, as I am not currently active in any of the trailblazer communities. I plan to change that for next time. Example - the Non-Profit community had lots of activities, and <a href="https://invite.salesforce.com/yournonprofithubfordreamforce">even a website</a> to help you find the right sessions and events! I didn’t know about it until the event was starting and had already planned my schedule.</p>

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>Dreamforce is massive, and it’s easy to feel overwhelmed. The key is to plan ahead but stay flexible. Prioritize the hands-on sessions and roundtables for meaningful learning and networking, and don’t stress about missing keynotes—they’re available on-demand. Much like Disney World, you won’t see everything on your first visit, but that just leaves you with more to look forward to next time.</p>

<p>Would I do it again? The answer is yes, if I stay involved in the Salesforce community and continue to build my skills it would be worth it. I heard from many attendees that it gets better each year as you build your network and know what to expect, and can start branching out into hyper focused areas of interest.</p>

<hr />

<h2 id="useful-resources">Useful Resources</h2>

<ul>
  <li><a href="https://trailhead.salesforce.com/content/learn/modules/dreamforce-ready-2023/get-the-most-out-of-dreamforce-2023">Dreamforce Ready 2024</a>: Salesforce’s official prep guide for getting the most out of the event.</li>
  <li><a href="https://www.salesforce.com/plus/">Salesforce+</a>: Watch on-demand Dreamforce sessions and continue learning after the event.</li>
  <li><a href="https://www.salesforceben.com/official-dreamforce-parties-and-events-guide-2024/">Salesforce Ben - Parties and Events Guide</a>: A guide to Dreamforce parties and events.</li>
  <li><a href="https://www.salesforce.com/blog/road-to-dreamforce/">2024 Dreamforce Quests</a>: Salesforce’s guide to the Dreamforce quests for earning swag and completing challenges.</li>
</ul>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="salesforce" /><category term="dreamforce" /><summary type="html"><![CDATA[A First-Timer’s Guide to Navigating Dreamforce 2024]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_header_dreamforce.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_header_dreamforce.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Eleventy Experiment - Salesforce User Directory</title><link href="https://box464.com/posts/salesforce-eleventy-directory/" rel="alternate" type="text/html" title="Eleventy Experiment - Salesforce User Directory" /><published>2024-07-13T06:11:45-05:00</published><updated>2024-07-13T06:11:45-05:00</updated><id>https://box464.com/posts/salesforce-eleventy-directory</id><content type="html" xml:base="https://box464.com/posts/salesforce-eleventy-directory/"><![CDATA[<h1 id="eleventy-experiment-salesforce-user-directory">Eleventy Experiment: Salesforce User Directory</h1>
<p>This is a simple experiment to see if I can use Eleventy to generate a small user directory from a Salesforce SOQL query. Spoiler - it works!</p>

<p>I’m using plain <code class="language-plaintext highlighter-rouge">fetch</code> and the Salesforce REST API to log in and run a SOQL query to get a list of users. I’m then using Eleventy to generate a static site with the user data.</p>

<blockquote>
  <p><strong>Update, October 2026:</strong> This post originally used the JSForce library and logged in with a username, password and security token. I’ve since replaced all of that with <code class="language-plaintext highlighter-rouge">fetch</code> and an OAuth client credentials login. No password or token to babysit, and the install dropped from about 236 packages to about 130. The post below reflects the new version.</p>
</blockquote>

<p>Obviously not a real-world use case, but it’s a fun experiment to see how Eleventy can be used to generate static sites from Salesforce data.</p>

<p>Just want to skip right to the code? <a href="https://github.com/jeffsikes/salesforce-eleventy-directory">Here’s the GitHub repo</a></p>

<h2 id="project-setup">Project Setup</h2>
<p>I wanted to keep things simple, so I’m using very limited dependencies. Here’s what you need to get started:</p>

<ol>
  <li>Install <a href="https://nodejs.org/">Node.js</a> 20.12 or newer (it has built-in <code class="language-plaintext highlighter-rouge">.env</code> loading, so no dotenv needed)</li>
  <li>Clone the repo and run <code class="language-plaintext highlighter-rouge">npm install</code>. That pulls in <a href="https://www.11ty.dev/">Eleventy</a>, which is the only dependency.</li>
</ol>

<p>I decided against using a CSS framework for simplicity. If I did use one, tho, for these little lab experiments I’d probably use <a href="https://picocss.com/">Pico.css</a>.</p>

<h2 id="salesforce-setup">Salesforce Setup</h2>
<p>You’ll need some information from your Salesforce instance to get started.</p>

<table>
  <thead>
    <tr>
      <th>Required Fields</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Salesforce URL</td>
      <td>The URL for your Salesforce instance. For example, <code class="language-plaintext highlighter-rouge">https://mycompany.my.salesforce.com</code>.</td>
    </tr>
    <tr>
      <td>Salesforce Client ID</td>
      <td>The Consumer Key from an External Client App you create (steps below).</td>
    </tr>
    <tr>
      <td>Salesforce Client Secret</td>
      <td>The Consumer Secret from the same app.</td>
    </tr>
  </tbody>
</table>

<h3 id="creating-an-external-client-app">Creating an External Client App</h3>
<p>Salesforce no longer lets you create new Connected Apps, so this uses an External Client App with the OAuth client credentials flow. No password or security token needed.</p>

<ol>
  <li>In Setup, open App Manager and click <strong>New External Client App</strong>. Give it a name, a contact email, and set distribution state to <strong>Local</strong>.</li>
  <li>Under <strong>API (Enable OAuth Settings)</strong>, check <strong>Enable OAuth</strong>, and use <code class="language-plaintext highlighter-rouge">https://localhost</code> as the callback URL (required by the form, never used).</li>
  <li>Add the <strong>Manage user data via APIs (api)</strong> scope.</li>
  <li>Check <strong>Enable Client Credentials Flow</strong> and accept the warning. Create the app.</li>
  <li>On the app’s <strong>Policies</strong> tab, edit the OAuth policies, enable the client credentials flow, and set <strong>Run As</strong> to the user the app should act as. That user needs API access and permission to read User records. For production, use a dedicated integration user.</li>
  <li>On the <strong>Settings</strong> tab, open OAuth Settings and click <strong>Consumer Key and Secret</strong>. Salesforce may email you a verification code first.</li>
</ol>

<p>The app can take a few minutes to start working after you create it.</p>

<p>Your URL, client ID and client secret go in a <code class="language-plaintext highlighter-rouge">.env</code> file in the root of the project. The URL needs to be your My Domain URL with no trailing slash, since the client credentials flow doesn’t work against <code class="language-plaintext highlighter-rouge">login.salesforce.com</code>. Here’s what mine looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>SF_LOGIN_URL={your My Domain URL}
SF_CLIENT_ID={your consumer key}
SF_CLIENT_SECRET={your consumer secret}
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This information will not be included in the final published website. It’s only used to generate the user directory data during build.</p>

<h2 id="the-code">The Code</h2>
<p>There’s only a few main functions for this project:</p>

<h3 id="logging-into-salesforce">Logging into Salesforce</h3>
<p>The <code class="language-plaintext highlighter-rouge">loginToSalesforce</code> function posts to the Salesforce token endpoint using the OAuth client credentials flow. It uses the <code class="language-plaintext highlighter-rouge">SF_CLIENT_ID</code> and <code class="language-plaintext highlighter-rouge">SF_CLIENT_SECRET</code> environment variables, then asks the org which REST API version is newest and uses that.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="kd">const</span> <span class="nx">loginToSalesforce</span> <span class="o">=</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">SF_LOGIN_URL</span><span class="p">}</span><span class="s2">/services/oauth2/token`</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/x-www-form-urlencoded</span><span class="dl">'</span> <span class="p">},</span>
        <span class="na">body</span><span class="p">:</span> <span class="k">new</span> <span class="nc">URLSearchParams</span><span class="p">({</span>
            <span class="na">grant_type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">client_credentials</span><span class="dl">'</span><span class="p">,</span>
            <span class="na">client_id</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">SF_CLIENT_ID</span><span class="p">,</span>
            <span class="na">client_secret</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">SF_CLIENT_SECRET</span>
        <span class="p">})</span>
    <span class="p">});</span>
    <span class="c1">// ...store the access token and instance URL, pick the newest API version</span>
<span class="p">};</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="searching-salesforce-with-soql">Searching Salesforce with SOQL</h3>
<p>The <code class="language-plaintext highlighter-rouge">searchUsers</code> function runs a SOQL query through a small <code class="language-plaintext highlighter-rouge">query</code> helper that calls the REST API and follows <code class="language-plaintext highlighter-rouge">nextRecordsUrl</code> for paging. The query pulls active users with their ID, Name, Email and Profile Photo. Profile photos are downloaded locally during build using the access token, since Salesforce requires auth to serve them.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
</pre></td><td class="rouge-code"><pre><span class="kd">const</span> <span class="nx">searchUsers</span> <span class="o">=</span> <span class="k">async </span><span class="p">(</span><span class="nx">authenticatedUserId</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">records</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">query</span><span class="p">(</span>
        <span class="dl">'</span><span class="s1">SELECT Id, FirstName, LastName, Email, SmallPhotoUrl FROM User WHERE IsActive = true LIMIT 100</span><span class="dl">'</span>
    <span class="p">);</span>

    <span class="kd">const</span> <span class="nx">authId15</span> <span class="o">=</span> <span class="nx">authenticatedUserId</span><span class="p">.</span><span class="nf">substring</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">15</span><span class="p">);</span>

    <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">user</span> <span class="k">of</span> <span class="nx">records</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">user</span><span class="p">.</span><span class="nx">isAuthenticatedUser</span> <span class="o">=</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Id</span><span class="p">.</span><span class="nf">substring</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">15</span><span class="p">)</span> <span class="o">===</span> <span class="nx">authId15</span><span class="p">;</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">SmallPhotoUrl</span><span class="p">)</span> <span class="p">{</span>
            <span class="kd">const</span> <span class="nx">localPath</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">downloadPhoto</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">SmallPhotoUrl</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Id</span><span class="p">);</span>
            <span class="nx">user</span><span class="p">.</span><span class="nx">LocalPhotoUrl</span> <span class="o">=</span> <span class="nx">localPath</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nx">records</span><span class="p">;</span>
<span class="p">};</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="generating-the-user-directory">Generating the User Directory</h3>
<p>Eleventy then works it’s magic to generate the data we’ll use for the directory. For this small experiment, I’m not writing the data to a file since I’m limiting the users to 100, but you could easily write it to a JSON file.</p>

<p>Here’s the core of the Eleventy config file. It adds the Salesforce users to the global data and creates a collection of users.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
</pre></td><td class="rouge-code"><pre><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">eleventyConfig</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">eleventyConfig</span><span class="p">.</span><span class="nf">addGlobalData</span><span class="p">(</span><span class="dl">"</span><span class="s2">env</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">SF_LOGIN_URL</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">SF_LOGIN_URL</span>
    <span class="p">});</span>
    <span class="nx">eleventyConfig</span><span class="p">.</span><span class="nf">addPassthroughCopy</span><span class="p">(</span><span class="dl">"</span><span class="s2">src/img</span><span class="dl">"</span><span class="p">);</span>

    <span class="nx">eleventyConfig</span><span class="p">.</span><span class="nf">addCollection</span><span class="p">(</span><span class="dl">"</span><span class="s2">salesforceUsers</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="kd">function</span><span class="p">(</span><span class="nx">collectionApi</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">userInfo</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">loginToSalesforce</span><span class="p">();</span>
        <span class="kd">const</span> <span class="nx">users</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">searchUsers</span><span class="p">(</span><span class="nx">userInfo</span><span class="p">.</span><span class="nx">id</span><span class="p">);</span>
        <span class="k">return</span> <span class="nx">users</span><span class="p">;</span>
    <span class="p">});</span>

    <span class="k">return</span> <span class="p">{</span>
        <span class="na">dir</span><span class="p">:</span> <span class="p">{</span>
            <span class="na">input</span><span class="p">:</span> <span class="dl">"</span><span class="s2">src</span><span class="dl">"</span><span class="p">,</span>
            <span class="na">output</span><span class="p">:</span> <span class="dl">"</span><span class="s2">dist</span><span class="dl">"</span>
        <span class="p">}</span>
    <span class="p">};</span>
<span class="p">};</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="the-html-template">The HTML Template</h3>
<p>The <code class="language-plaintext highlighter-rouge">index.njk</code> file is the template for the user directory. It’s a simple list of users with their name, email and profile photo. Profile images are served from locally downloaded copies.</p>

<p>I’m a big fan of <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/microformats">microformats</a>, so I’ve added some basic h-card markup to the user list. This is a simple way to add some semantic meaning to the user data. You can also use these to style the user list with CSS.</p>

<p>In addition, I attempted to make the site <a href="https://www.bocoup.com/blog/getting-started-with-voiceover-accessibility">voiceover accessible</a>. I’m not an expert in this area, but I tried to make sure the site was navigable with a screen reader.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"h-card"</span> <span class="na">role=</span><span class="s">"listitem"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"{linkToProfileImage}"</span> <span class="na">alt=</span><span class="s">"Profile Image"</span> <span class="na">class=</span><span class="s">"u-photo"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"user-info"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{linkToProfilePage}"</span> <span class="na">class=</span><span class="s">"u-url p-name blur-text"</span><span class="nt">&gt;</span>FirstName LastName<span class="nt">&lt;/a&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"p-email blur-text"</span><span class="nt">&gt;</span>email@address.com<span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<blockquote>
  <p>I noticed that eleventy is kind of messing up my HTML indentation. I’ll have to look into that.</p>
</blockquote>

<p>Search was added client side with some simple javascript. It’s not perfect, but it works for this experiment.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
</pre></td><td class="rouge-code"><pre><span class="kd">function</span> <span class="nf">filterUsers</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">searchBox</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">search-box</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">filter</span> <span class="o">=</span> <span class="nx">searchBox</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nf">toLowerCase</span><span class="p">();</span>
    <span class="kd">const</span> <span class="nx">userGrid</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">user-grid</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">users</span> <span class="o">=</span> <span class="nx">userGrid</span><span class="p">.</span><span class="nf">getElementsByClassName</span><span class="p">(</span><span class="dl">'</span><span class="s1">h-card</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">let</span> <span class="nx">visibleCount</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

    <span class="k">for </span><span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">users</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">name</span> <span class="o">=</span> <span class="nx">users</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nf">getElementsByClassName</span><span class="p">(</span><span class="dl">'</span><span class="s1">p-name</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="nx">textContent</span><span class="p">.</span><span class="nf">toLowerCase</span><span class="p">();</span>
        <span class="kd">const</span> <span class="nx">email</span> <span class="o">=</span> <span class="nx">users</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nf">getElementsByClassName</span><span class="p">(</span><span class="dl">'</span><span class="s1">p-email</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="nx">textContent</span><span class="p">.</span><span class="nf">toLowerCase</span><span class="p">();</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">name</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">filter</span><span class="p">)</span> <span class="o">||</span> <span class="nx">email</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">filter</span><span class="p">))</span> <span class="p">{</span>
            <span class="nx">users</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="dl">''</span><span class="p">;</span>
            <span class="nx">visibleCount</span><span class="o">++</span><span class="p">;</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="nx">users</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">none</span><span class="dl">'</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">const</span> <span class="nx">noResults</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">no-results</span><span class="dl">'</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">visibleCount</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">noResults</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">No users were found matching your criteria.</span><span class="dl">"</span><span class="p">;</span>
        <span class="nx">noResults</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">remove</span><span class="p">(</span><span class="dl">'</span><span class="s1">hidden</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="nx">noResults</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">""</span><span class="p">;</span>
        <span class="nx">noResults</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="dl">'</span><span class="s1">hidden</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="the-css">The CSS</h3>
<p>I used flex grid to layout the user list. It’s simple and works well for this use case.</p>

<p>You may notice that I’m using a <code class="language-plaintext highlighter-rouge">blur-text</code> class. Because I wanted to demo this publicly with screenshots and video, I’ve blurred out the user data for everyone except the authenticated user (determined by the credentials in <code class="language-plaintext highlighter-rouge">.env</code>). This is a simple way to do it with CSS.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>    <span class="nc">.blur-text</span> <span class="p">{</span>
        <span class="nl">filter</span><span class="p">:</span> <span class="nf">blur</span><span class="p">(</span><span class="m">5px</span><span class="p">);</span> <span class="c">/* Apply blur effect */</span>
    <span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I have to say, for me, the styling is always the hardest part. I’m not a designer, so I tend to keep things simple. I’m a big fan of <a href="https://picocss.com/">Pico.css</a> for these small projects.</p>

<h2 id="running-the-project">Running the Project</h2>
<p>To run the project, you’ll need to exceute the following command at the terminal:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>npm start
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="known-issues">Known Issues</h2>
<p>None at this time. Profile images are now downloaded during build and served locally, which fixed the old SSL problem where images wouldn’t show in Chrome or Firefox when running on HTTP.</p>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="salesforce" /><category term="eleventy" /><category term="oauth" /><category term="soql" /><summary type="html"><![CDATA[Eleventy Experiment: Salesforce User Directory This is a simple experiment to see if I can use Eleventy to generate a small user directory from a Salesforce SOQL query. Spoiler - it works!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_eleventy_salesforce.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_eleventy_salesforce.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Updating a Mastodon Instance</title><link href="https://box464.com/posts/updating-mastodon/" rel="alternate" type="text/html" title="Updating a Mastodon Instance" /><published>2024-07-04T06:11:45-05:00</published><updated>2024-07-04T06:11:45-05:00</updated><id>https://box464.com/posts/updating-mastodon</id><content type="html" xml:base="https://box464.com/posts/updating-mastodon/"><![CDATA[<p>My objective here is to document the process of updating my Mastodon instance from 4.2.9 to 4.2.10, an urgent security release. I’ll then reference this when future upgrades are necessary.</p>

<p>This is for users that installed Mastodon directly on a server, not using Docker. I’m using the Digital Ocean Mastodon Droplet, which is a one-click (<a href="https://www.digitalocean.com/community/tutorials/how-to-install-mastodon-with-digitalocean-marketplace-1-click">lolz</a>) install of Mastodon.</p>

<h1 id="read-the-release-notes">Read the Release Notes</h1>
<p>Always start with the release notes. What’s new? Are there any incompatible changes? How long will the update take? I’m a single user instance, so I can just take it down for a bit. But if you’re running a larger instance, you may need to plan ahead, let your users know, and schedule a time for the update.</p>

<p>This is an <a href="https://github.com/mastodon/mastodon/releases/tag/v4.2.10">urgent security release</a> and while the update steps are listed, this isn’t where you need to start. You need to start with the <a href="https://docs.joinmastodon.org/admin/upgrading/">upgrade guide</a> and follow the steps there.</p>

<h1 id="backup">Backup</h1>
<p>Before you do anything, backup your instance. I utilize Digital Ocean’s system level backup, which gives you a daily backup, and then a snapshot of the current state right before the update is processed. It’s handy and easy to setup.</p>

<h1 id="switching-to-the-mastodon-user">Switching to the Mastodon User</h1>
<p>Log into your mastodon server and enjoy the adorable Mastodon ascii art for a moment. Then switch over to the mastodon user.</p>

<p><img src="/assets/img/posts/mastodon-ascii-art.png" alt="Mastodon ASCII Art" /></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>su - mastodon
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Finally, navigate to the Mastodon root directory, which is usually at:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">cd</span> /home/mastodon/live
</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="pull-the-latest-code">Pull the Latest Code</h1>
<p>Now we need to pull the latest code changes down. Let’s get the latest tags first.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>get fetch <span class="nt">--tags</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then checkout the tag for the relase you’re wanting to upgrade to. In this case, it’s 4.2.10.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>git checkout v4.2.10
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Now it’s time to jump back to the upgrade instructions for this specific release.</p>

<h1 id="update-steps-in-release-notes">Update Steps in Release Notes</h1>
<p>In this <a href="https://github.com/mastodon/mastodon/releases/tag/v4.2.10">4.2.10 release</a>, you have to install dependencies and then restart all mastodon processes.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>bundle <span class="nb">install
</span>yarn <span class="nb">install</span> <span class="nt">--frozen-lockfile</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I did not know what “–frozen-lockfile” did exactly, so <a href="https://classic.yarnpkg.com/lang/en/docs/cli/install/#toc-yarn-install-frozen-lockfile">I looked it up</a> before running.</p>

<p>At this point, you can exit out of the mastodon user and go back to your regular user.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">exit</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="restart-everything">Restart Everything</h1>
<p>Now, jump back to the <a href="https://docs.joinmastodon.org/admin/upgrading/">Mastodon upgrade guide</a> and follow the steps to restart all the Mastodon processes.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>systemctl restart mastodon-sidekiq
systemctl reload mastodon-web
systemctl restart mastodon-streaming
</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="check-the-version-number">Check the Version Number</h1>
<p>Did the update work? Let’s check the version number, which appears at the bottom of the Mastodon home page.</p>

<p><img src="/assets/img/posts/mastodon-version-number.png" alt="Mastodon Version Number" /></p>

<h1 id="conclusion">Conclusion</h1>
<p>Upgrading a Mastodon instance can be more complicated with major milestone releases, like the upcoming 4.3.0 release, but for now, I’m up to date and secure. The key I think is to <a href="https://github.com/mastodon/mastodon/releases/tag/v4.2.10">read the release notes thoroughly</a> and always follow the <a href="https://docs.joinmastodon.org/admin/upgrading/">upgrade guide</a>.</p>

<p>I hope this helps you with your Mastodon updates.</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://docs.joinmastodon.org/admin/upgrading/">Upgrading to a new Mastodon release</a></li>
  <li><a href="https://github.com/mastodon/mastodon/releases/tag/v4.2.10">Mastodon 4.2.10 Release Notes</a></li>
  <li><a href="https://nts.strzibny.name/freezing-dependencies-yarn-lock-frozen-lockfile/">Yarn Install Frozen Lockfile</a></li>
</ul>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="fediverse" /><category term="mastodon" /><summary type="html"><![CDATA[My objective here is to document the process of updating my Mastodon instance from 4.2.9 to 4.2.10, an urgent security release. I’ll then reference this when future upgrades are necessary.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_header_updating_mastodon.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_header_updating_mastodon.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Installing the FediBuzz relay</title><link href="https://box464.com/posts/fediverse-relays/" rel="alternate" type="text/html" title="Installing the FediBuzz relay" /><published>2024-04-08T17:11:45-05:00</published><updated>2024-04-08T17:11:45-05:00</updated><id>https://box464.com/posts/fediverse-relays</id><content type="html" xml:base="https://box464.com/posts/fediverse-relays/"><![CDATA[<p>Let’s take a look a fediverse relays, and in particular, FediBuzz. We’ll go over the basics of how relays work, and then dive into a fresh install on Ubuntu.</p>

<h1 id="objective">Objective</h1>
<p>My objective for this project is to understand how relay works, and complete an install of the FediBuzz relay from my home lab.</p>

<h1 id="what-is-a-relay">What is a relay?</h1>
<p>For small instances on the fediverse, it can be lonely. Unlike siloed social feeds such as Twitter or Facebook, you are only going to see content from people you follow and people they interact with. If your instance is large, or even a small enthusiastic group of followers around a common subject, a relay may not be necessary. But for very small or single user instances, you can miss out on a lot of good content.</p>

<p>A relay is an application that listens to the public feeds of many fediverse instances, pulls them together, and provides a way for smaller instances to subscribe to a portion of incoming content that would have been missing otherwise. This featureset is built into many platforms and is accessible by the adminstrators of the instance.</p>

<h1 id="how-does-it-work">How does it work?</h1>
<p>The relay service reaches out to many instances and pulls in their public feeds. Mastodon now has public feeds secured by access tokens, so relays must collect a token for each server it connects to. Once access has been secured, the relay starts pulling in the streaming public feeds of the instance.</p>

<p>I can only speak to FediBuzz, but an interesting aspect here is that none of the content is stored locally other than the follow relationships. The posts themselves appear in the live feed stream and then disappear just as quickly. FediBuzz listens to the streams and determines if any of it’s relay followers are interested in the posts - if so, they are delivered to the appropriate relay inboxes.</p>

<h1 id="whats-fedibuzz">What’s FediBuzz?</h1>
<p><a href="https://github.com/astro/buzzrelay">FediBuzz</a> is one such relay, one of the more popular ones.</p>

<p>Platforms that I am aware of that support using administrator managed relays are:</p>

<ul>
  <li><a href="https://fedi.tips/using-relays-to-quickly-expand-a-servers-view-of-the-fediverse/">Mastodon</a></li>
  <li><a href="https://wiki.friendi.ca/docs/initicalconfiguration">Friendica</a></li>
  <li><a href="https://docs.pleroma.social/backend/administration/CLI_tasks/relay/">Plemora</a></li>
  <li><a href="https://docs.akkoma.dev/stable-docs/administration/CLI_tasks/relay/">Akkoma</a></li>
  <li>Forkeys (Misskey, Sharkey, Firefish, Catodon, IceShrimp)</li>
</ul>

<p>Documentation is fairly spotty for this feature. It’s simple to configure, however, once you find it.</p>

<p>If your platform of choice isn’t listed, there are still ways to connect to a relay without being an administrator.</p>

<h1 id="relay-types">Relay Types</h1>
<p>FediBuzz has 3 types of relays: Hashtag, Instance and Language.</p>

<h2 id="hashtag-relay">Hashtag Relay</h2>
<p>With this relay, you can subscribe to a specific hashtag and only content related to that hashtag will be added to your server.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>https://relay.fedi.buzz/tag/knitting 🧶
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="instance-relay">Instance Relay</h2>
<p>You can also follow an entire instance. But it’s not quite what you would expect. You are only going to get the posts for that instance as found on the relays you’re attached to.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>https://relay.fedi.buzz/instance/fluffy.family 🐶
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For example, if my relay is attached to the public feed of dotnet.social and hachyderm.io and I ask to get all posts from mastodon.social, it’s only going to send over posts from mastodon.social users that have made it to dotnet.social or hachyderm.io.</p>

<h2 id="language-relay">Language Relay</h2>
<p>The same goes for the last type of content - language. You can also relay all content from a specific language, and ignore the rest!</p>

<p>For languages, you have to know the abbreviations for this to work. “es” is for Spanish.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>https://relay.fedi.buzz/language/es 🇲🇽
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Here’s a list of all known language abbreviations available on a Mastodon instance.</p>

<details>
<summary>All Language Abbreviations</summary>
<p>
<table>
  <tr>
    <th>Language</th>
    <th>Value</th>
  </tr>
  <tr><td>Afrikaans</td><td>af</td></tr>
  <tr><td>aragonés</td><td>an</td></tr>
  <tr><td>اللغة العربية</td><td>ar</td></tr>
  <tr><td>Asturianu</td><td>ast</td></tr>
  <tr><td>беларуская мова</td><td>be</td></tr>
  <tr><td>български език</td><td>bg</td></tr>
  <tr><td>বাংলা</td><td>bn</td></tr>
  <tr><td>brezhoneg</td><td>br</td></tr>
  <tr><td>bosanski jezik</td><td>bs</td></tr>
  <tr><td>Català</td><td>ca</td></tr>
  <tr><td>سۆرانی</td><td>ckb</td></tr>
  <tr><td>corsu</td><td>co</td></tr>
  <tr><td>čeština</td><td>cs</td></tr>
  <tr><td>Cymraeg</td><td>cy</td></tr>
  <tr><td>dansk</td><td>da</td></tr>
  <tr><td>Deutsch</td><td>de</td></tr>
  <tr><td>Ελληνικά</td><td>el</td></tr>
  <tr><td>English</td><td>en</td></tr>
  <tr><td>English (British)</td><td>en-GB</td></tr>
  <tr><td>Esperanto</td><td>eo</td></tr>
  <tr><td>Español</td><td>es</td></tr>
  <tr><td>Español (Argentina)</td><td>es-AR</td></tr>
  <tr><td>Español (México)</td><td>es-MX</td></tr>
  <tr><td>eesti</td><td>et</td></tr>
  <tr><td>euskara</td><td>eu</td></tr>
  <tr><td>فارسی</td><td>fa</td></tr>
  <tr><td>suomi</td><td>fi</td></tr>
  <tr><td>føroyskt</td><td>fo</td></tr>
  <tr><td>Français</td><td>fr</td></tr>
  <tr><td>Français (Canadien)</td><td>fr-QC</td></tr>
  <tr><td>Frysk</td><td>fy</td></tr>
  <tr><td>Gaeilge</td><td>ga</td></tr>
  <tr><td>Gàidhlig</td><td>gd</td></tr>
  <tr><td>galego</td><td>gl</td></tr>
  <tr><td>עברית</td><td>he</td></tr>
  <tr><td>हिन्दी</td><td>hi</td></tr>
  <tr><td>Hrvatski</td><td>hr</td></tr>
  <tr><td>magyar</td><td>hu</td></tr>
  <tr><td>Հայերեն</td><td>hy</td></tr>
  <tr><td>Bahasa Indonesia</td><td>id</td></tr>
  <tr><td>Asụsụ Igbo</td><td>ig</td></tr>
  <tr><td>Ido</td><td>io</td></tr>
  <tr><td>Íslenska</td><td>is</td></tr>
  <tr><td>Italiano</td><td>it</td></tr>
  <tr><td>日本語</td><td>ja</td></tr>
  <tr><td>ქართული</td><td>ka</td></tr>
  <tr><td>Taqbaylit</td><td>kab</td></tr>
  <tr><td>қазақ тілі</td><td>kk</td></tr>
  <tr><td>ಕನ್ನಡ</td><td>kn</td></tr>
  <tr><td>한국어</td><td>ko</td></tr>
  <tr><td>Kurmancî</td><td>ku</td></tr>
  <tr><td>Kernewek</td><td>kw</td></tr>
  <tr><td>latine</td><td>la</td></tr>
  <tr><td>lietuvių kalba</td><td>lt</td></tr>
  <tr><td>latviešu valoda</td><td>lv</td></tr>
  <tr><td>македонски јазик</td><td>mk</td></tr>
  <tr><td>മലയാളം</td><td>ml</td></tr>
  <tr><td>मराठी</td><td>mr</td></tr>
  <tr><td>Bahasa Melayu</td><td>ms</td></tr>
  <tr><td>ဗမာစာ</td><td>my</td></tr>
  <tr><td>Nederlands</td><td>nl</td></tr>
  <tr><td>Norsk Nynorsk</td><td>nn</td></tr>
  <tr><td>Norsk</td><td>no</td></tr>
  <tr><td>occitan</td><td>oc</td></tr>
  <tr><td>ਪੰਜਾਬੀ</td><td>pa</td></tr>
  <tr><td>Polski</td><td>pl</td></tr>
  <tr><td>Português (Brasil)</td><td>pt-BR</td></tr>
  <tr><td>Português (Portugal)</td><td>pt-PT</td></tr>
  <tr><td>Română</td><td>ro</td></tr>
  <tr><td>Русский</td><td>ru</td></tr>
  <tr><td>संस्कृतम्</td><td>sa</td></tr>
  <tr><td>sardu</td><td>sc</td></tr>
  <tr><td>Scots</td><td>sco</td></tr>
  <tr><td>සිංහල</td><td>si</td></tr>
  <tr><td>slovenčina</td><td>sk</td></tr>
  <tr><td>slovenščina</td><td>sl</td></tr>
  <tr><td>Shqip</td><td>sq</td></tr>
  <tr><td>српски језик</td><td>sr</td></tr>
  <tr><td>Srpski (latinica)</td><td>sr-Latn</td></tr>
  <tr><td>Svenska</td><td>sv</td></tr>
  <tr><td>ślůnsko godka</td><td>szl</td></tr>
  <tr><td>தமிழ்</td><td>ta</td></tr>
  <tr><td>తెలుగు</td><td>te</td></tr>
  <tr><td>ไทย</td><td>th</td></tr>
  <tr><td>Türkçe</td><td>tr</td></tr>
  <tr><td>татар теле</td><td>tt</td></tr>
  <tr><td>ئۇيغۇرچە‎</td><td>ug</td></tr>
  <tr><td>Українська</td><td>uk</td></tr>
  <tr><td>اردو</td><td>ur</td></tr>
  <tr><td>Tiếng Việt</td><td>vi</td></tr>
  <tr><td>ⵜⴰⵎⴰⵣⵉⵖⵜ</td><td>zgh</td></tr>
  <tr><td>简体中文</td><td>zh-CN</td></tr>
  <tr><td>繁體中文（香港）</td><td>zh-HK</td></tr>
  <tr><td>繁體中文（臺灣）</td><td>zh-TW</td></tr>
</table>
</p>
</details>

<h1 id="why-not-just-use-fedibuzz-instead-of-installing-it-yourself">Why not just use FediBuzz instead of installing it yourself?</h1>
<p>Great question. You should!</p>

<p>FediBuzz likely already has access to that same instance and much more.</p>

<p>The OG fedibuzz relay doesn’t provide a list of instances for which they are connected. But as of April 2024, I was provided that 329 unique domains are included. It’s more than enough to provide a solid set of data for your small instance.</p>

<p>I did add a single additional instance, just to make sure it worked. It did. I can see this useful if you want to build out a relay for a specific topic, or you want to include a collection of smaller instances that are related to that community.</p>

<h1 id="skip-the-install">Skip the install</h1>
<p>Aren’t interested in learning how to install a relay? Just <a href="#final-thoughts">skip to my summary</a>.</p>

<h1 id="homelab-configuration">Homelab Configuration</h1>
<p>My homelab consists of the following:</p>

<ul>
  <li>Router Configured to forward 80, 443 through a firewall to NGINX Proxy Manager</li>
  <li>NGINX Proxy Manager then directs traffic based on the incoming domain name to the appropriate spot.</li>
  <li>A ProxMox server with an Ubuntu VM hosting the FediRelay application.</li>
</ul>

<h2 id="hardware">Hardware</h2>
<p>I used a ProxMox VM running in my homelab, configured with all the normal things - SSH access via keys only, snapshots, backups, etc.</p>

<p>This could be run very cheaply on any hosting site like Hetzner. I’m curious if this could even be run as a Cloudflare worker in some way (rewritten of course).</p>

<ul>
  <li>Ubuntu 22.04.4 LTS ISO Image</li>
  <li>1 Core</li>
  <li>1 GB RAM</li>
</ul>

<p>The official fedi.buzz relay traffics around 330 public instance streams for around 3,500 unique followers (some may have multiple relay requests) as of April 2024.</p>

<p>If you’re connecting to the fedi.buzz relay and perhaps one or two others on your own relay, this should be more than enough.</p>

<p>It doesn’t take much to run this. Why? Because once compiled, this is doing just a few queued up tasks. One is receiving incoming streams of data from fediverse servers. Then, checking to see if that content is wanted by anyone using the relay. If it is, it’s queued up for delivery.</p>

<p>At no point are the posts physically stored in a database, they are held in memory for distribution out to other servers.</p>

<p>The only item stored locally are the server names (actors) and their interested tags, instances and languages so the content can be distributed out appropriately.</p>

<p>FediBuzz has a mostly unknown option that allows individual users to utilize relays as well. If you promote this alternative route and many individuals start connecting to your relay, it will absolutely cause more outgoing traffic for you as you deliver the same messages to multiple inboxes rather than just to the server’s relay itself.</p>

<h2 id="domain-name">Domain Name</h2>
<p>As with most fediverse projects, you’re going to need a domain. In this particular instance, I used a subdomain of https://relay.{yourdomain}.</p>

<h2 id="nginx-proxy-manager-config">NGINX Proxy Manager Config</h2>
<p>I needed a wildcard SSL for my domain, and used the PorkBun API to make that work. Here’s a <a href="https://blog.jverkamp.com/2023/03/27/wildcard-lets-encrypt-certificates-with-nginx-proxy-manager-and-cloudflare/">blog post about Cloudflare</a>, but the setup is very similar for PorkBun.</p>

<p>My relay is using wildcard SSL cert pointing to my ProxMox VM. I’m not sure websockets are needed but I enabled it in NGINX Proxy Manager during configuration.</p>

<h2 id="firewall">Firewall</h2>
<p>I wish I could say I knew all this up front, but this took me quite awhile to get right.</p>

<p>The following ports need to be open on the server running FediRelay. I’m using ufw here.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>## Default is 3000 in the FediBuzz docs, but that port was already taken
sudo ufw allow 3020 
## Default is 6378 for Redis Cache, but my install was on 6379
## Uou can probably skip this as redis isn't required.
sudo ufw allow 6379
## Allow SSH traffic so you can connect - consider limiting to specific IPs
sudo ufw allow 22
</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="server-configuration">Server configuration</h1>
<p>Now you have your environment. It’s time to set it up!</p>

<h2 id="initial-server-installs">Initial server installs</h2>
<p>These packages are required for rust / cargo to work.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>sudo apt-get update
sudo apt-get install pkg-config libssl-dev libsystemd-dev git cargo curl
</pre></td></tr></tbody></table></code></pre></div></div>

<p>curl was already installed for me.</p>

<h2 id="rust-and-related-tooling-install">Rust and related tooling install</h2>
<p>Ensure Rust is installed on your server. Ubuntu has a rustc installation included by default, but it is likely not the latest version. In addition, you may prefer to use rustup to manage your  install. Recommended to follow <a href="https://www.rust-lang.org/tools/install">the official installation guide</a>.</p>

<h2 id="pull-github-repo">Pull GitHub Repo</h2>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>git clone https://github.com/astro/buzzrelay.git
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="excluded-features">Excluded Features</h2>
<p>If you audit the official FediBuzz Relay site, you’ll notice that there are a few additional pages beyond the relay home page.</p>

<p>https://fedi.buzz shows trending hashtags across the known instances. This page is NOT included in the relay github repository.</p>

<p>https://fedi.buzz/token/donate is not part of the relay repository either.</p>

<h1 id="postgresql-configuration">PostgreSQL configuration</h1>
<p>Install a PostgreSQL database, I followed <a href="https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-22-04">this guide</a> and it worked fine.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>createuser --interactive
</pre></td></tr></tbody></table></code></pre></div></div>

<ul>
  <li>Role (user) to add: relay as superuser</li>
</ul>

<p>Password wasn’t set, so from a PostgreSQL prompt:</p>

<p><code class="language-plaintext highlighter-rouge">ALTER USER relay WITH PASSWORD 'your-secure-password';</code></p>

<p>Then create database:</p>

<p><code class="language-plaintext highlighter-rouge">CREATE DATABASE buzzrelay;</code></p>

<p>Then grant the relay user with full rights to the database:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>ALTER DATABASE buzzrelay OWNER TO relay;
</pre></td></tr></tbody></table></code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>GRANT ALL PRIVILEGES ON DATABASE buzzrelay TO relay;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO relay;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO relay;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO relay;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO relay; 
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO relay; 
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO relay;
GRANT USAGE, CREATE ON SCHEMA public TO relay;
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="querying-the-database">Querying the database</h2>
<p>A cheat sheet for connecting connecting to the database.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>sudo -u postgres psql
\c buzzrelay
</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="redis-configuration">Redis Configuration</h1>
<p>The instructions show an optional redis configuration and is included in the confg.yaml. I initially installed this, but after discussions with the author, it’s not used in this version of FediRelay.</p>

<p>This was used if you are going to host the page shown at <a href="https://fedi.buzz">https://fedi.buzz</a> which doesn’t come with the open sourced relay code.</p>

<p>It’s really not necessary to install this then. Just comment out the associated lines in the YAML file and be done with it.</p>

<p>Here’s my notes on it because I lost a lot of time on this piece, so if for whatever reason you DO want this to work, leaving the notes here. <strong>Most of you should skip this!</strong></p>

<details>
<summary>Troubleshooting the redis install</summary>
<p>
Followed this gude for <a href="https://www.digitalocean.com/community/tutorials/how-to-install-and-secure-redis-on-ubuntu-22-04" target="_blank">installing Redis on Ubuntu</a>
</p>
<p>
I ran into an issue with using the suggested redis_password.txt file to store the password. 
</p>
<p>
I used vim to create a file, then copied my password in from a random password generator I have locally. Whenever the app would attempt to use the file to authenticate, it didn't work.
</p>
<p>
I found two issues:
</p>
<ol>
<li>If there were non-alpha numeric values in the password, something in the code was url escaping the value before submitting for authorization, making authentication fail. I removed all non-alpha numeric values and it worked.</li>
<li>Even when I couldn't see them, some line breaks were causing issues with the authentication to fail as well. I had to run some special commands in vim to get rid of them.</li>
</ol>
<p>
So with a newline and special chars being url encoded, of course the password didn't match.
</p>
<p>
To get rid of newlines at the end of the file using vim:
</p>
<code>
:set noendofline binary
:w
</code>
<p></p>
<p>
Then be sure to remove all non alphanumerics from the password.
</p>
<p>
With those two issues resolved, redis is now working.
</p>
</details>

<h1 id="update-the-configyaml">Update the config.yaml</h1>
<p>Several items in this file need to be updated, see below.</p>

<h2 id="streams">Streams</h2>
<ul>
  <li>Leave the fedibuzz stream as is.</li>
  <li>Comment out the first example.</li>
  <li>Change the last example to your instance’s url and token.</li>
  <li>Add as many others as desired.</li>
</ul>

<h3 id="additional-filters-for-streams">Additional filters for streams</h3>
<p>If you have a token for an instance that you are using to connect to a mastodon public stream, you’re not limited to just the federated stream of all posts! If you want to get more granular, these streaming timelines work, too.</p>

<p>All of the items listed below have a /local/ version as well if you want to get REALLY granular and only pick up posts from the local instance.</p>

<blockquote>
  <p>This does not work for the default fedibuzz relay stream, only for mastodon servers for which you have an access token.</p>
</blockquote>

<p><strong>Public remote posts only - federated posts excluding local ones</strong></p>

<p>You can also pass a “only_media” parameter in the querystring and get back only posts with some type of attachment (audio, image, or video) Cool!</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nf">GET</span> <span class="nn">/api/v1/streaming/public/remote?only_media={true|false}&amp;access_token={yourAccessToken}</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Public posts with a specific hashtag</strong></p>

<blockquote>
  <p>This one does not has the only_media parameter unfortunately.</p>
</blockquote>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nf">GET</span> <span class="nn">/api/v1/streaming/hashtag?tag={yourTag}&amp;access_token={yourAccessToken}</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Watch a list for posts</strong>
Quite interesting here. For the user with the associated token, you can create a list of accounts and pass the list_id to this query. It will return only posts from those accounts.</p>

<blockquote>
  <p>Is this how setups like NewsMast and Flipboard are working behind the scenes?</p>
</blockquote>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nf">GET</span> <span class="nn">/api/v1/streaming/list?list={yourListId}&amp;access_token={yourAccessToken}</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
</pre></td></tr></tbody></table></code></pre></div></div>
<h2 id="hostname">Hostname</h2>
<p>Set it to your domain. I used the sub-domain format of “relay.{yourdomain}”</p>

<h2 id="private-key-file">Private Key File</h2>
<p>Generate a new RSA key pair for signing ActivityPub messages. Note using this command also sets the appropriate permissions values.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>openssl genrsa <span class="nt">-out</span> private-key.pem 4096 
openssl rsa <span class="nt">-in</span> private-key.pem <span class="nt">-pubout</span> <span class="nt">-out</span> public-key.pem
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="postgresql-password">PostgreSQL Password</h3>
<p>Since I used the default user and dbname listed here, I just need to update the password.</p>
<h2 id="build-it">Build it</h2>
<p>From the root of the buzzrelay project, with the config.yaml finalized, run the following.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>cargo build --release
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This creates a compiled version in the target/release folder.</p>

<p>From the root of the project, you can run this command to start up the app:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>cargo run --release config.yaml
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This is where I ran into issues with redis, so be sure to comment out those lines in the config.yaml if you see that error here - it is NOT needed.</p>

<p>With the fedi relay public stream enabled, I see the following error stream quite often, showing that the uri is missing, which it is.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>2024-03-23T03:39:34.773184Z TRACE buzzrelay::relay: data: {"created_at":"2024-03-23T03:39:33.020Z","url":"https://some.instance/notes/9r73vj18yk","content":"&lt;p&gt;&lt;a href=\"https://some.instance/@some.user\" class=\"u-url mention\"&gt;@some.user&lt;/a&gt;&lt;span&gt; Some Content​&lt;/p&gt;","account":{"username":"some.user","display_name":"some.display.name","url":"https://some.instance/@some.user","bot":true},"tags":[],"sensitive":false,"mentions":[],"language":"ja","media_attachments":[],"reblog":null}
2024-03-23T03:39:48.745870Z ERROR buzzrelay::relay: parse error: missing field `uri` at line 1 column 746
</pre></td></tr></tbody></table></code></pre></div></div>

<p>However, even with that error, content is coming in for at least the hashtag and instance relay types, so I move on.</p>

<h1 id="start-using-your-relay">Start using your relay</h1>
<p>And with that, I had a running relay! Now you have to add it to your server. How?</p>

<p>Visit the url you setup for the hostname and hopefully you see this. Follow the instructions and copy the resulting URL.</p>

<p><img src="/assets/img/posts/post-relay-fedibuzz.png" alt="Fedibuzz Relay Screenshot" /></p>

<p>Normally, you have to be an administrator of a server to then register a relay to be used with a specific instance. I only have a Mastodon instance to play with, but this is found under Administration &gt; Relays.</p>

<p>From your relay homepage (public), you can decide on the type of relay you want, and enter your content. Then copy the url into the Administration area. Refresh the page, and you should see that your relay has accepted the request.</p>

<p><img src="/assets/img/posts/post-relay-mastodon-admin.png" alt="Mastodon Relay Admin Screenshot" /></p>

<p>But you don’t have to be an admin! As an end user, you can also follow specific tags. Surprisingly, they do NOT have to already exist - you can just create it as an end user. It doesn’t seem to TRULY exist until someone follows it, tho.</p>

<p>So I can follow @tag-dogsofmastodon@relay.com from my single user GoToSocial instance, even tho my GtS server doesn’t have a concept of relays.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>@instance-superpets.social@relay.com
@language-es@relay.com
</pre></td></tr></tbody></table></code></pre></div></div>

<p>If you follow this way, as an end-user, you’re going to see these in your feed as a “boost” from this account. Like “<code class="language-plaintext highlighter-rouge">@language-es@relay.com</code> has boosted this post”</p>

<p>You’re done! You can add a few more tags and instances if you want, but you should start seeing relay content streaming in to your instance now.</p>

<p>Congratulations!</p>

<h1 id="next-steps">Next steps</h1>
<p>Check out the /metrics endpoint at {yourRelayDomain}/metrics for information about the current status of your relay.</p>

<p>Additionally, once you stabilize the settings, you may want to set this up to run automatically after a system reboot.</p>

<div class="language-ssh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="k">sudo</span> nano /etc/systemd/system/buzzrelay.service
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Edit the new service file, change the working directory to your own location.</p>

<pre><code class="language-nano">[Unit]
Description=Buzzrelay Rust Application
After=network.target

[Service]
Type=simple
User=box464
WorkingDirectory=/home/box464/buzzrelay
ExecStart=/home/box464/.cargo/bin/cargo run --release /home/box464/buzzrelay/config.yaml
Restart=on-failure

[Install]
WantedBy=multi-user.target
</code></pre>

<p>Finally, enable the service, start it, and check the status.</p>

<div class="language-ssh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="k">sudo</span> systemctl daemon-reload
<span class="k">sudo</span> systemctl enable buzzrelay.service
<span class="k">sudo</span> systemctl start buzzrelay.service
<span class="k">sudo</span> systemctl status buzzrelay.service

</pre></td></tr></tbody></table></code></pre></div></div>

<h1 id="final-thoughts">Final Thoughts</h1>
<p><span id="final"></span>
This isn’t a long term install for me, I just wanted to see if it worked. I think the most surprising aspect was how few resources are needed for this to be stood up.</p>

<p>The incoming requests are streamed in, and never stored locally. As they come in, they are pushed into a queue for processing. If the server goes down for a while, there’s no queue to store them - those posts will just not be included in the relay.</p>

<p>And I think that’s fine for what a relay is attempting - a way to fill in content on a small instance.</p>

<h2 id="missing-uri-in-posts">Missing Uri in Posts</h2>
<p>There are a lot of errors shown in the debug console for posts that don’t have a uri. I assume all those posts are being dropped.</p>

<p>I changed my local version to replace the missing Uri with the Url field if it’s missing. And it seems to work. At least, the error goes away.</p>

<h2 id="moderation">Moderation</h2>
<p>If I was using this long term, I would be a bit concerned about administrators and users being able to enter anything they want to follow, and there’s no real moderation happening.</p>

<p>I would probably disable the public facing homepage and have a submission request form instead.</p>

<p>I might even list all the relays that have already been setup for hashtags, instances and languages on the homepage instead so they can just choose those up front if they want, or submit a request for a new one.</p>

<h1 id="ideas-to-consider">Ideas to consider</h1>
<p>Now that I have a working instance, I may work on a few tweaks. But don’t hold your breath.</p>

<p>I can see this being used kind of like a Bluesky Custom Feed.</p>

<ul>
  <li>Allow the administrator of the relay to set all content from instances should only include posts with media, including the default fedibuzz relay. (this could cost users of the relay large amounts in CDN fees!)</li>
  <li>Allow the administrator of the relay to set excluded hashtags - similar to how only specific tags that are being followed, also set some hashtags that should be blocked from the stream. A good example - exclude posts with the NSFW tag or the NOBOT tag in a user’s profile, etc.</li>
  <li>Allow the administrator to setup a flag to exclude sensitive posts.</li>
</ul>

<h1 id="references">References</h1>
<ul>
  <li><a href="https://github.com/astro/buzzrelay">FediBuzz Relay Source Code</a></li>
  <li><a href="https://joinfediverse.wiki/Fediverse_relays">What is a fediverse relay?</a></li>
  <li><a href="https://github.com/emilebosch/awesome-fediverse">Awesome Fediverse</a></li>
  <li><a href="https://github.com/jaywink/social-relay">Social Relay - Diaspora</a></li>
  <li><a href="https://git.asonix.dog/asonix/relay">Aode Relay</a></li>
  <li><a href="https://git.pleroma.social/pleroma/relay">LitePub Relay - Pleroma</a></li>
</ul>]]></content><author><name>Jeff Sikes</name><email>jeff@box464.com</email></author><category term="fediverse" /><category term="relays" /><summary type="html"><![CDATA[Let’s take a look a fediverse relays, and in particular, FediBuzz. We’ll go over the basics of how relays work, and then dive into a fresh install on Ubuntu.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://box464.com/assets/img/posts/post_header_fedibuzz_relay.png" /><media:content medium="image" url="https://box464.com/assets/img/posts/post_header_fedibuzz_relay.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>