Skip to main content

I Rage-Quit WordPress and Built My Own Rails Site (Here's What Broke)

TL;DR

I migrated humadroid’s website from WordPress to Rails because I was tired of fighting with hosting providers and plugin conflicts. Built custom scraping scripts, exported everything to Markdown, and automated SEO optimization with background jobs. Made several painful mistakes: forgot to remove old URLs from Search Console, used 301 redirects instead of 410 status codes, and nearly tanked my SEO with trailing slash inconsistencies. Now I have a faster site, cleaner code, and automated content workflows. Worth it.

Why I Broke Up With WordPress

Look, WordPress is fine. For some people. But I was spending more time wrestling with my CMS than building my actual product.

The breaking point came when:

  • My hosting provider had another outage
  • A plugin update broke my contact forms (again)
  • I needed to customize something simple and ended up in PHP hell
  • The irony hit me: I’m building Humadroid, an AI-powered compliance platform that automates tedious work, while I’m manually babysitting WordPress

If my pitch is “replace $200k consultants with AI automation,” maybe I should start by automating my own website management?

The Migration Plan (Spoiler: Some Parts Failed)

Step 1: Figure Out What I Actually Have

First rule of migration: know what you’re migrating. I built a quick script to crawl my entire WordPress site and capture everything:

  {
    "url": "https://humadroid.io/posts/how-startups-can-get-soc-2-compliance-without-a-security-team/",
    "path": "/posts/how-startups-can-get-soc-2-compliance-without-a-security-team/",
    "title": "How Startups Can Get SOC 2 Compliance Without a Security Team - Humadroid",
    "meta_description": "Discover how early-stage startups can achieve SOC 2 compliance using a step-by-step guide, without hiring full-time security staff",
    "canonical": "https://humadroid.io/posts/how-startups-can-get-soc-2-compliance-without-a-security-team/",
    "og_title": "How Startups Can Get SOC 2 Compliance Without a Security Team",
    "og_description": "Discover how early-stage startups can achieve SOC 2 compliance using a step-by-step guide, without hiring full-time security staff",
    "og_image": "https://humadroid.io/wp-content/uploads/2025/06/SOC-2-Journey-for-Startups.jpg",
    "h1_tags": [
      "How Startups Can Get SOC 2 Compliance Without a Security Team",
      "Don’t miss these tips!"
    ],
    "word_count": 1464,
    "internal_links_count": 81
  },

This saved me later when I needed to verify nothing got lost. Metadata matters—months of SEO work lives in those title tags and descriptions.

Step 2: Export Content Without the WordPress Cruft

WordPress’s built-in export is… not great. Instead, I used the “WordPress to Jekyll Exporter” plugin. It dumps everything as clean Markdown files with YAML front matter:

---
id: 3480
title: 'ISO 27001 Internal Audit: Step-by-Step Guide'
date: '2025-05-30T13:28:56+01:00'
author: 'Bartek Hamerliński'
excerpt: 'Step into ISO 27001 internal audits with confidence our detailed, step-by-step guide covers planning, execution, reporting, and follow-up to help your organization ensure compliance and continual improvement.'
layout: post
guid: 'https://humadroid.io/?p=3480'
permalink: /posts/iso-27001-internal-audit-step-by-step-guide/
...
image: /wp-content/uploads/2025/05/SO-27001-Internal-Audit-–-Step-by-Step-Process-Illustration.jpg
categories:
    - Certification
    - 'Compliance & Governance'
    - ISO
    - 'Knowledge Hub'
language:
    - English
---

An ISO 27001 internal audit is a systematic and independent assessment of an organization’s Information Security Management System (ISMS) to ensure it conforms to the standard’s requirements and to identify areas for improvement...

Perfect. Portable, version-controllable, no PHP in sight.

There is also one more thing to it - wordpress pages are build from blocks. Good luck figuring out all the database schema to fetch data directly.

Step 3: Build the Rails Site

I used Lovable to generate the initial layout. Basically described what I wanted, got a solid frontend foundation, then customized from there.

For the blog:

  • Dynamic Markdown rendering
  • Custom Post model with SEO fields
  • Background jobs for optimization (more on this later)

Step 4: The Pivot Tax

Here’s the thing: Humadroid started as HR/HRMS software. Then I pivoted hard to GRC (compliance management) in 2024. Completely different market.

This meant intentionally abandoning 30+ blog posts about HR topics. They were getting traffic, but the wrong traffic. Someone looking for “employee onboarding software” isn’t interested in “SOC 2 compliance automation.”

Better to rank well for one thing than confuse everyone about what I actually build.

What I Broke (And How I Fixed It)

Mistake #1: Forgetting About Google Search Console

What I did wrong: Deleted old HR content from my site and sitemap. Assumed Google would figure it out.

What actually happened: Those pages stayed in search results showing 404 errors for weeks. Not a great look.

The fix: You have to explicitly tell Google to remove URLs. Go to Search Console → Removals → “Temporarily remove URLs.” This forces a re-crawl instead of waiting for Google to notice eventually.

Lesson learned: Google is not psychic about your content strategy.

Mistake #2: The 301 Redirect Trap

What I did wrong: Created a pivot announcement page and redirected all old HR content there with 301s. It was done as simple rescue from ActiveRecord::RecordNotFound exception.

Why this sucked: A 301 says “this content permanently moved here.” But 30 different HR articles didn’t all “move” to one pivot announcement. The content was removed, not relocated.

The right approach: Return HTTP 410 (Gone):

# routes.rb
get "/pl/*path" => "welcome#grc_pivot_explanation"
get "/by_tag/*" => "welcome#grc_pivot_explanation"

# welcome_controller.rb
def grc_pivot_explanation
  render status: 410
end

# posts_controller.rb
def set_post
  @post = Post.friendly.find(params[:id])
rescue ActiveRecord::RecordNotFound
  render "welcome/grc_pivot_explanation", status: 410
end

410 tells Google “stop looking for this, it’s never coming back.” Search engines deindex 410s faster than 404s.

I had to learn this the hard way by watching shitload of old urls still being visited.

Mistake #3: The Trailing Slash Nightmare

This one nearly killed my SEO.

The problem: WordPress URLs always end with /. Rails doesn’t care—/blog-post and /blog-post/ both work. But Google treats them as different URLs, potentially splitting your link equity.

Since my WordPress URLs were already indexed with trailing slashes, I needed to keep that convention.

The fix (three parts):

  1. Set Rails default in config/application.rb:
config.routes.default_url_options[:trailing_slash] = true
  1. Force redirects in the controller:
class PostsController < ApplicationController
  before_action :enforce_trailing_slash, only: [:show]
  
  private
  
  def enforce_trailing_slash
    unless request.original_fullpath.to_s.ends_with?("/")
      redirect_to post_path(@post), status: :moved_permanently
    end
  end
end
  1. Update sitemap to consistently use trailing slashes.

This catches non-trailing URLs that Google already indexed and redirects them properly.

Win: Sitemap Updates Are Actually Easy

One thing that did work smoothly: the sitemap_generator gem.

Refreshing the sitemap after publishing content is literally:

SitemapGenerator::Interpreter.run

That’s it. Generates a fresh sitemap and pings search engines.

I trigger this automatically when posts are saved:

# In Post model
after_commit :refresh_sitemap, on: [:create, :update]

private

def refresh_sitemap
  SitemapRefreshJob.perform_later
end

No manual XML editing. No forgetting to update it. Just works.

The Fun Part: Automating SEO Like I Automate Compliance

Here’s where I get to practice what I preach. Humadroid’s whole value prop is “automate the tedious compliance work.” So why was I manually filling in meta descriptions?

Every time I save a blog post, three background jobs fire:

Job 1: Generate Missing Metadata

class GeneratePostMetadataJob < ApplicationJob
  def perform(post_id)
    post = Post.find(post_id)

    # Only generate if fields are still empty
    return if post.lead_text.present? && post.seo_description.present?

    # Skip if content is empty
    return if content_text.blank?

    # Use tool-based approach for Claude (works better than schema)
    chat = RubyLLM.chat
    response = chat.with_tool(GenerateMetadataTool).ask(
      "Analyze this blog post and use the generate_metadata tool to provide a compelling lead text (2-3 sentences) and an SEO-optimized meta description (max 160 characters).\n\nTitle: #{post.title}\n\nContent:\n#{content_text.truncate(8000)}"
    )

    # The tool returns the metadata directly
    metadata = response.content
    
    # Update the post with generated metadata
    if metadata.is_a?(Hash) && metadata[:lead_text] && metadata[:seo_description]
      post.update_columns(
        lead_text: metadata[:lead_text],
        seo_description: metadata[:seo_description]
      )
    end
  end

If I forget to write a meta description, the system extracts one automatically. Good enough? Usually. Perfect? No. But better than blank.

Job 2: Generate TL;DR

class GeneratePostTldrJob < ApplicationJob
def perform(post_id)
    post = Post.find(post_id)

    # Only generate if tldr is empty
    return if post.tldr.present?

    # Skip if content is empty
    return if content_text.blank?

    # Use tool-based approach for Claude
    chat = RubyLLM.chat
    response = chat.with_tool(GenerateTldrTool).ask(
      "Analyze this blog post and use the generate_tldr tool to provide a concise TL;DR summary (1-2 sentences capturing the key takeaway).\n\nTitle: #{post.title}\n\nContent:\n#{content_text.truncate(8000)}"
    )

    # The tool returns the tldr directly
    result = response.content

    # Update the post with generated tldr
    if result.is_a?(Hash) && result[:tldr]
      post.update_columns(
        tldr: result[:tldr]
      )
    end
  end
end

Long posts need summaries. I use AI to generate them automatically rather than forcing myself to write two versions of everything.

Job 3: Insert CTAs Automatically

class InsertPostCtaJob < ApplicationJob
  def perform(post_id)
    post = Post.find(post_id)

    # Only insert if CTA_BUTTON is not already present
    content_html = post.content.to_s
    return if content_html.include?("CTA_BUTTON")

    # Skip if content is empty
    return if content_html.blank?

    # Use tool-based approach for Claude
    chat = RubyLLM.chat
    response = chat.with_tool(InsertCtaTool).ask(
      "Analyze this blog post and insert the marker 'CTA_BUTTON' at the most contextually appropriate location. The marker should:\n" \
      "1. Be placed on its own line (as a paragraph: <p>CTA_BUTTON</p>)\n" \
      "2. Come after introducing a key concept, pain point, or problem\n" \
      "3. NOT be in the introduction or conclusion\n" \
      "4. Be placed where a call-to-action would naturally fit in the narrative\n" \
      "5. Appear only ONCE in the content\n\n" \
      "Title: #{post.title}\n\n" \
      "Content (HTML format):\n#{content_html.truncate(10000)}"
    )

    # The tool returns the content with CTA inserted
    result = response.content
    
    # Update the post content with CTA inserted
    if result.is_a?(Hash) && result[:content_with_cta].present?
      new_content = result[:content_with_cta]
      
      # Verify CTA_BUTTON was actually inserted
      if new_content.include?("CTA_BUTTON")
        post.content = new_content
        post.save(validate: false) # Skip validations to avoid triggering other callbacks
      end
    end
  end
end

At render time, {{CTA_BUTTON}} becomes an actual call-to-action button. Consistent CTAs across all posts without manual placement.

The pattern: Write content → Automation handles optimization → I focus on making the content good.

Same philosophy as Humadroid: automate the tedious execution, let humans do the creative work.

What I Actually Gained

Speed: Page loads dropped 60%. No plugin bloat, no theme builder overhead.

Control: Want to change something? Edit the code. No more “which of these 47 plugins is breaking my forms?”

Automation: SEO optimization happens in the background. No checklist to forget.

Sanity: My website stack now matches my product philosophy. Automate the tedious stuff, focus on what matters.

Also, I can version control everything. Try doing that with WordPress page builders.

If You’re Thinking About Migrating

Here’s your realistic checklist:

  • Crawl your site first and document everything
  • Use 410 (Gone) for actually removed content, not 301 redirects
  • Pick a trailing slash convention and enforce it everywhere
  • Manually remove old pages in Search Console
  • Automate sitemap updates from day one
  • Build SEO automation hooks early
  • Use Google’s URL Inspection tool religiously
  • Expect 6-8 weeks of traffic weirdness while Google adjusts

And honestly? Expect to break something. I broke three things. You’ll probably break different things. That’s fine—you’ll fix them and learn more than you would have staying comfortable in WordPress.

The Point

I’m building Humadroid because manual compliance management doesn’t scale. Spreadsheet tracking fails. $200k/year consultants don’t scale. AI automation scales.

Same logic applies to my website: automate SEO optimization, automate content workflows, automate anything that must be done correctly but doesn’t need human creativity.

Whether it’s generating SOC 2 documentation in minutes (instead of weeks) or auto-optimizing blog metadata, the pattern is identical: humans think, AI executes.

Also, I’m done debugging PHP.


Want to see how this automation-first approach works for compliance? Check out Humadroid—I built it to replace expensive consultants with 24/7 AI assistance. Same philosophy, different problem.

And if you’re migrating your own site and hit weird issues, feel free to reach out. I’ve probably already made that mistake.