In this tutorial, we'll walk through creating a modern URL shortener application called "YLL" (Your Link Shortener) using Rails 8. By the end, you'll have a fully functional application with features like link expiration and password protection.
Introduction
URL shorteners are web applications that create shorter aliases for long URLs. When users access these short links, they are redirected to the original URL. Our application, YLL, will provide:
- Short, unique codes for any URL
- Optional expiration dates
- Password protection
- Click tracking
- A full JSON API
Prerequisites
- Ruby 3.4.2
- Rails 8.0.1
- Basic knowledge of Ruby on Rails
Project Setup
Let's start by creating a new Rails 8 application:
# Install the latest Rails version if you haven't already
gem install rails -v 8.0.1
# Create a new Rails application
rails new yll --database=sqlite3
# Navigate to the project directory
cd yll
Database Design
Our application revolves around a single model: Link. Let's create it:
rails generate model Link url:string code:string:index password_digest:string expires_at:datetime clicks:integer
Now let's run the migration:
rails db:migrate
The Link Model
Edit the app/models/link.rb file to implement our model's business logic:
class Link < ApplicationRecord
  has_secure_password validations: false
  # Validations
  validates :url, presence: true,
                  format: {
                    with: URI::DEFAULT_PARSER.make_regexp(%w[http https]),
                    message: "must be a valid HTTP/HTTPS URL"
                  }
  validates :code, presence: true,
                   uniqueness: true,
                   length: { is: 8 }
  validate :validate_url_security
  validate :validate_url_availability, if: -> { url.present? && errors[:url].none? }
  validate :expires_at_must_be_in_future, if: -> { expires_at.present? }
  # Callbacks
  before_validation :normalize_url
  before_validation :generate_unique_code, on: :create
  def to_param
    code
  end
  def to_json(*)
    {
      original_url: url,
      short_url: short_url,
      created_at: created_at,
      expires_at: expires_at,
      code: code,
      clicks: clicks
    }.to_json
  end
  def expired?
    expires_at.present? && expires_at <= Time.current
  end
  def short_url
    Rails.application.routes.url_helpers.redirect_url(code)
  end
  private
  def normalize_url
    return if url.blank?
    begin
      uri = Addressable::URI.parse(url).normalize
      self.url = uri.to_s
    rescue Addressable::URI::InvalidURIError => e
      errors.add(:url, "contains invalid characters or format", e.message)
    end
  end
  def generate_unique_code
    self.code ||= loop do
      random_code = SecureRandom.alphanumeric(8)
      break random_code unless self.class.exists?(code: random_code)
    end
  end
  def validate_url_security
    return if errors[:url].any?
    uri = URI.parse(url)
    errors.add(:url, "must use HTTPS protocol") unless uri.scheme == "https"
  rescue URI::InvalidURIError
    # Already handled by format validation
  end
  def validate_url_availability
    response = Faraday.head(url) do |req|
      req.options.open_timeout = 3
      req.options.timeout = 5
    end
    unless response.success? || response.status == 301 || response.status == 302
      errors.add(:url, "could not be verified (HTTP #{response.status})")
    end
  rescue Faraday::Error => e
    errors.add(:url, "could not be reached: #{e.message}")
  end
  def expires_at_must_be_in_future
    errors.add(:expires_at, "must be in the future") if expires_at <= Time.current
  end
end
Setting Up Dependencies
Add the required gems to your Gemfile:
# Add these to your Gemfile
gem "addressable", "~> 2.8"
gem "faraday", "~> 2.7"
gem "bcrypt", "~> 3.1.16"
Then install the gems:
bundle install
Creating Controllers
Redirects Controller
First, let's create the controller that will handle the redirection:
rails generate controller Redirects show
Now, edit app/controllers/redirects_controller.rb:
class RedirectsController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound, with: :link_not_found
  before_action :set_link, only: :show
  before_action :authenticate, only: :show, if: -> { @link.password_digest.present? }
  after_action :increment_clicks, only: :show, if: -> { response.status == 302 }
  def show
    if @link.expired?
      render file: Rails.root.join("public", "410.html"), status: :gone, layout: false
    else
      # Brakeman: ignore
      redirect_to @link.url, allow_other_host: true
    end
  end
  private
  def authenticate
    authenticate_or_request_with_http_basic("Links") do |username, password|
      username == @link.code && @link.authenticate(password)
    end
  end
  def increment_clicks
    @link.increment!(:clicks)
  end
  def set_link
    @link = Link.find_by!(code: params[:code])
  end
  def link_not_found
    render json: { error: "Link not found" }, status: :not_found
  end
end
API Controller
Now, let's create the API controller for programmatic access:
rails generate controller Api::V1::Links create show
Edit app/controllers/api/v1/links_controller.rb:
module Api
  module V1
    class LinksController < ApplicationController
      rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests }
      protect_from_forgery with: :null_session
      # POST /api/v1/links
      def create
        link = Link.new(link_params)
        if link.save
          render json: link.to_json, status: :created
        else
          render json: { errors: link.errors.full_messages }, status: :unprocessable_entity
        end
      end
      # GET /api/v1/links/:code
      def show
        link = Link.find_by(code: params[:code])
        if link
          render json: link.to_json
        else
          render json: { error: "Link not found" }, status: :not_found
        end
      end
      private
      def link_params
        params.permit(:url, :password, :expires_at)
      end
    end
  end
end
Setting Up Routes
Edit config/routes.rb to define our application routes:
Rails.application.routes.draw do
  # API routes
  namespace :api do
    namespace :v1 do
      resources :links, only: [:create, :show], param: :code
    end
  end
  # Redirect route
  get 'r/:code', to: 'redirects#show', as: :redirect
  # Root route (for a future web interface)
  root 'links#new'
end
Application Controller
Update app/controllers/application_controller.rb to handle cache control and modern browsers:
class ApplicationController < ActionController::Base
  # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  allow_browser versions: :modern
  protect_from_forgery with: :exception, unless: -> { request.format.json? }
  before_action :set_cache_control_headers
  private
  def set_cache_control_headers
    response.headers["Cache-Control"] = "no-store"
  end
end
Basic Web Interface
Let's create a simple web interface for creating links. First, generate a Links controller for the web interface:
rails generate controller Links new create
Edit app/controllers/links_controller.rb:
class LinksController < ApplicationController
  def new
    @link = Link.new
  end
  def create
    @link = Link.new(link_params)
    if @link.save
      redirect_to link_path(@link.code), notice: 'Link successfully created!'
    else
      render :new, status: :unprocessable_entity
    end
  end
  def show
    @link = Link.find_by!(code: params[:code])
  end
  private
  def link_params
    params.require(:link).permit(:url, :password, :expires_at)
  end
end
Now create the views:
<!-- app/views/links/new.html.erb -->
<h1>Create a Short Link</h1>
<%= form_with(model: @link, url: links_path) do |form| %>
  <% if @link.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@link.errors.count, "error") %> prohibited this link from being saved:</h2>
      <ul>
        <% @link.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div class="field">
    <%= form.label :url %>
    <%= form.url_field :url, required: true %>
  </div>
  <div class="field">
    <%= form.label :password, "Password (optional)" %>
    <%= form.password_field :password %>
  </div>
  <div class="field">
    <%= form.label :expires_at, "Expiration (optional)" %>
    <%= form.datetime_local_field :expires_at %>
  </div>
  <div class="actions">
    <%= form.submit "Create Short Link" %>
  </div>
<% end %>
<!-- app/views/links/show.html.erb -->
<h1>Your Short Link</h1>
<div>
  <p><strong>Original URL:</strong> <%= @link.url %></p>
  <p><strong>Short URL:</strong> <a href="<%= @link.short_url %>"><%= @link.short_url %></a></p>
  <p><strong>Created:</strong> <%= @link.created_at.to_s(:long) %></p>
  <% if @link.expires_at.present? %>
    <p><strong>Expires:</strong> <%= @link.expires_at.to_s(:long) %></p>
  <% end %>
  <% if @link.password_digest.present? %>
    <p><strong>Password Protected:</strong> Yes</p>
  <% end %>
  <p><strong>Clicks:</strong> <%= @link.clicks %></p>
</div>
<div>
  <%= link_to "Create Another Link", new_link_path %>
</div>
Adding Error Pages
Create custom error pages:
<!-- public/404.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Link not found (404)</title>
  <style>
    /* Your styling here */
  </style>
</head>
<body>
  <div class="error-page">
    <h1>404 - Link Not Found</h1>
    <p>The requested link does not exist.</p>
    <a href="/">Back to Home</a>
  </div>
</body>
</html>
<!-- public/410.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Link has expired (410)</title>
  <style>
    /* Your styling here */
  </style>
</head>
<body>
  <div class="error-page">
    <h1>410 - Link Expired</h1>
    <p>The requested link has expired and is no longer available.</p>
    <a href="/">Back to Home</a>
  </div>
</body>
</html>
Testing the Application
Start your Rails server:
rails server
Visit http://localhost:3000 in your browser to use the web interface, or use the API to create links programmatically:
# Creating a link via API
curl -X POST "http://localhost:3000/api/v1/links" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "password": "mysecurepassword",
    "expires_at": "2025-12-31T23:59:59Z"
  }'
Security Considerations
Our application includes several security features:
- HTTPS Enforcement: Only HTTPS URLs are allowed
- URL Validation: URLs are validated for format and availability
- Password Protection: Links can be password-protected
- Rate Limiting: Prevents abuse of the API
- Modern Browser Requirement: Only modern browsers are supported
Docker and Deployment
YLL includes Docker support for easy deployment. Here's a basic Dockerfile:
# syntax=docker/dockerfile:1
# check=error=true
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.2
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git pkg-config && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
You'll also need a docker-entrypoint script to prepare the database:
#!/bin/bash
set -e
# Remove a potentially pre-existing server.pid for Rails.
rm -f tmp/pids/server.pid
# Run database migrations
if [ -f db/schema.rb ]; then
  echo "Running database migrations..."
  bundle exec rails db:migrate
fi
# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
Don't forget to make it executable:
chmod +x bin/docker-entrypoint
Deployment with Kamal
YLL supports deployment with Kamal, a modern deployment tool for Ruby on Rails applications:
- Install the Kamal gem:
gem install kamal
- Initialize Kamal in your project:
kamal init
- Edit the generated config/deploy.ymlfile:
service: yll
image: username/yll
registry:
  username: registry_username
  password:
    - KAMAL_REGISTRY_PASSWORD
servers:
  web:
    hosts:
      - your-server-ip
    labels:
      traefik.http.routers.yll.rule: Host(`yll.yourdomain.com`)
env:
  clear:
    DATABASE_URL: postgres://username:password@db-host/yll_production
    RAILS_ENV: production
    HOST_URL: https://yll.yourdomain.com
volumes:
  - /path/on/host/storage:/rails/storage
- Deploy your application:
kamal setup
kamal deploy
Code on GitHub
The complete source code for YLL is hosted on GitHub and serves as a valuable resource for anyone looking to understand how the application works, study its structure, or contribute to its development.
You can access the repository here: Yll
Conclusion
You've successfully built a modern URL shortener application with Ruby on Rails 8! YLL provides a solid foundation that you can extend with more features like:
- A more polished web interface
- User accounts to manage links
- Advanced analytics
- QR code generation for shortened links
- Browser extensions
The complete code for this project is available on GitHub, and you can use it as a starting point for your own URL shortener service.
 

 
    
Top comments (2)
Nice! You can try use base62 method to encode
idto make the unique codeThis looks great! I love the features you're implementing, especially the link expiration and password protection.