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.yml
file:
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 (0)