Book Of Secret Words - an Anonymous Feedback App (and guide on how to make one)

Recently there has been a huge surge in "Say something about XYZ. They will never know who said it" type of websites. Almost everyday, at least one of my friends is popping out with "Guys, please write something...". So I decided to cash in on it by making one of my own.

I'll first explain the structure of the site, and then explain how you can make one too. The site is made using Ruby On Rails and is running in a docker container on Google Cloud Platform. Find it here -  Book Of Secret Words

Here's a quick explanation of the website -

After registering, you will get a confirmation email.

After confirming your account and logging in, you'll be taken to your Dashboard page, where you can get your personal link.

You can copy this link and share to your friends. When they click on the link they'll be taken to a page where they can write whatever they want.

After saying their words, they'll be taken to the registration page with prompting them to register (need that sweet users)

Meanwhile the user gets an email notifying them about new feedbacks.

And on the dashboard they'll see it as well.

Enough talking. Let's get down to make one. I'll assume you know a little basics about Ruby on Rails and have it set up on your computer. In case you don't, or have never worked with Rails before, the official Getting Started guide is a good place to start.

Assuming you're all set to go, let's start by creating a new project that uses postgresql and skips tests and changing the directory into it-

rails new book-of-secret-words -d postgresql -T
cd book-of-secret-words

I'm using Ruby 2.6.1 and Rails 5.2.3 by the way.

Let's add the necessary gems. Edit the Gemfile and add these gems -

gem 'devise'
gem 'devise_uid'
gem 'haml'
gem 'bootstrap'
gem 'jquery-rails'
gem 'bootstrap-growl-rails'

Here's a brief explanation about each -

  1. Devise: Handle authentications. We will not make our own authentication mechanism. Instead we will use Devise like any responsible citizen.
  2. Devise UID: We will use this gem to add a unique random ID to each user, which we will use to generate the link.
  3. HAML: HTML is just so old school
  4. Bootstrap: For responsive design.
  5. JQuery: We're not going to write a lot of javascript (except the copy button). Still it's a good idea to use JQuery.
  6. Bootstrap Growl: Show notifications in a cool way.

Note: The repository (linked below) for this tutorial contains some tests, but we're not going to discuss testing in this tutorial.

Let's edit our Database configuration for development and testing. I have a postgres database running in localhost with user postgres and no password. In case you're having a different setup, tweak to your own liking. So here is the relevant setup in config/database.yml (comments stripped)

default: &default
  adapter: postgresql
  encoding: unicode
  host: localhost
  port: 5432
  username: postgres
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  <<: *default
  database: BookOfSecretWords_development
  username: postgres

test:
  <<: *default
  database: BookOfSecretWords_test
  


Create the database

rails db:create

Now we will install Devise by running -

rails g devise:install

You will see a few instructions on the console to get Devise working. We will take care of them in due time. For now, edit config/environments/development.rb and add the following lines somewhere before the closing end

config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 }

The first line is the only one which is needed to get Devise working. It sets the URL which will be used in the mails (e.g. confirmation mail, password reset mail etc.) Since in development the server will run at localhost:3000 that's what we have set it to.

Now the next two lines let us see the outgoing mails using the mailcatcher gem. Setting it up is easy as pie -

gem install mailcatcher

And run it

mailcatcher

Now in your browser navigate to localhost:1080 . The mails that will be sent in development will show up here.

Now we can get started with creating the User model. We will create this model with Devise which by default adds an email and password (and a lot of other stuff) -

rails g devise User

We want our Users to be "confirmable", that is, we will send a confirmation mail which users will have to click. For that, we need a little work. Edit the file app/models/user.rb and add :confirmable to the devise method -

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :confirmable

end

We need to change the migration file too. Open up db/migrate/[timestamp]-devise-create-users.rb (Note: the timestamp part will vary) and uncomment the confirmable section and the add_index for :confirmation_token part. Here is the full migration file -

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      # t.integer  :sign_in_count, default: 0, null: false
      # t.datetime :current_sign_in_at
      # t.datetime :last_sign_in_at
      # t.inet     :current_sign_in_ip
      # t.inet     :last_sign_in_ip

      ## Confirmable
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      # t.string   :unlock_token # Only if unlock strategy is :email or :both
      # t.datetime :locked_at


      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
  end
end

Now we can migrate

rails db:migrate

One more thing to get Devise going. We need a root path. For that we will create a controller named StaticPage. It will have two actions -

  1. home - This will show the starting page to the User and if he is logged in, he will be redirected to dashboard.
  2. dashboard - This page will host the link of the users and the list of the words spoken to him, and if his email is not confirmed, he will be sent back to home. (although this is strictly not necessary since Devise will take care of it)

So let's create the controller -

rails g controller StaticPage home dashboard

And finally, edit config/routes.rb. It should (roughly) look like this -

Rails.application.routes.draw do
  
  get 'static_page/home'
  get 'static_page/dashboard'
  devise_for :users
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

Remove the get 'static_page/home' and add root 'static_page#home' . This will ensure when you go to localhost:3000 you will be redirected to the home action of StaticPage controller.

At this point, your authentication should work perfectly. There's one more thing that is setting up the notifications, but we will do later

Try that by running the server

rails s

and go to localhost:3000 . Register a user, make sure the mail is shown in MailCatcher. Click the confirmation link and log in. Note that if you enter wrong information, or log in without confirming, you will get redirected to the home page without any explanation, and that's because we have not setup notifications. So let's fix it.

First we will setup JQuery and bootstrap which is as easy as 1, 2, 3

Edit app/assets/javascripts/application.js and before the //= require_tree . add the required dependencies, the whole thing should look like this -

//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require jquery3
//= require popper
//= require bootstrap-sprockets
//= require bootstrap-notify
//= require_tree .

Note that they are actually comments. Rails reads them and magically makes everything work.

Now edit app/assets/stylesheets/application.css . First we will change the extension to .scss and then add these.

@import "bootstrap";
@import "animate";
@import url('https://fonts.googleapis.com/css?family=Raleway:300,400&display=swap');
@import "static_page";

This sets up BootStrap, and adds the RaleWay font and imports the styles from static_page.scss.

Edit static_page.scss in the same folder and add these -

header {
  height: 100vh;
  background-image: linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.2)), image-url("hero.jpg");
  background-size: cover;
}
.lead, h2 {
  font-family: 'Raleway', sans-serif;
}
h1 {
  font-weight: 300;
  font-family: 'Raleway', sans-serif;
}

#reg-box {
  background-color: white;
  border-radius: 10px;
  padding: 30px;
}

#share {
  margin: 20px auto;
  padding: 20px;
  border: 2px solid #555555;
}

This sets some basic styles which are enough to make a website look decent. Now download a cool image that you want as a background and put in in app/assets/images/ and name it hero.jpg

Finally, edit app/views/layouts/application.html.erb and add inside the body tag, just before the yield -

<%= render partial: "partials/notify" %>

The full file should look like this

<!DOCTYPE html>
<html>
  <head>
    <title>Book Of Secret Words</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <%= render partial: "partials/notify" %>
    <%= yield %>
  </body>
</html>

Now let's add the partial. Create a folder called partials within app/views/ and add the file named _notify.html.haml(the underscore is needed) and add the following

- if !notice.blank?
  :javascript
      $(document).ready(function () {
        $.notify({
          message:  "#{notice}"
        });
      });
- if !alert.blank?
  :javascript
      $(document).ready(function () {
        $.notify({
          message:  "#{alert}"
        });
      });

This shows the notification if we have a notice or alert.

Now you should see notifications if you try to do something weird.

Let's finish off our authentication with changing the StaticPageController. Our home action should redirect to the dashboard if we are logged in. So, edit the file app/controllers/static_page_controller.rb and add the following line to home

if user_signed_in?
	redirect_to :dashboard
end

And the dashboardaction should redirect to home if the user has not confirmed email yet. So edit the dashboardaction with the following

unless current_user.confirmed?
	redirect_to :home, notice: "Your email is not confirmed. Please confirm using the link sent to your email."
end

Here is the full file:

class StaticPageController < ApplicationController
  before_action :authenticate_user!, only: [:dashboard]
  def home
    if user_signed_in?
      redirect_to :dashboard
    end
  end

  def dashboard
    unless current_user.confirmed?
      redirect_to :home, notice: "Your email is not confirmed. Please confirm using the link sent to your email."
    end
  end
end

Now we're starting to look good.

You might notice that the path of home and dashboard is pretty ugly. To get to dashboard you have to go to localhost:3000/static_page/dashboard. Ugly! We'd like, instead, to write localhost:3000/dashboard. So, let's edit config/routes.rb and remove the get 'static_page/dashboard and write

get 'dashboard', to: 'static_page#dashboard'

Now you can go to localhost:3000/dashboard to reach your dashboard.

All nice and good, but something's missing. Right! Our user doesn't have a name field. How would people know who they're writing to?

Let's fix that. Start by adding a migration

rails g migration add_name_to_users

This should create a migration in db/migrate/[timestamp]_add_name_to_users.rb. Here's what it should look like -

class AddNameToUsers < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :name, :string
  end
end

Now there's a slight problem. By default Devise doesn't allow any keys during sign up except email, password and password confirmation. This is required to prevent security issues. For example, suppose your User has a field called admin which decides if the user is admin or not. Even if you modify the registration forms to remove the admin field, someone still make an ajax call with an admin: true value and if your server accepts that, it's a nightmare.

In this case we tell devise to accept name during sign up.

Edit app/controllers/application_controller.rb. Create a protected method called configure_permitted_parameters with the following code

def configure_permitted_parameters
	devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
end

Now inside the same file, configure the before_action method as follows

before_action :configure_permitted_parameters, if: :devise_controller?

What this does is that, it tells rails to run the method we just created if the controller is a Devise controller.

The file should look like this

class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
  end
end

Now we need to show the name field during registration. Also, we would like to style the pages a little bit. First we need to copy the views used by devise to our server.

rails g devise:views

This copies all the views for devise to app/views/devise folder.

Here we will just change the sign up page to show the name field and style it with bootstrap, and also style the login page. I am not going to style the other pages (password reset, mails etc.) but you should definitely do.

Note. We could have just copied the views we need and not the entire bunch by using the following

rails generate devise:views -v registrations sessions

Edit the file app/views/devise/registration/new.html.erb

First We will wrap the entire thing (the stuff within the header tag) into a div of class container . We wrap each label and corresponding element in a form-group. Then within each form element we add the form-control class from bootstrap. We add the class btn btn-primary mb-2 to the button. Finally we add the following code for name

<div class="field form-group">
	<%= f.label :name %><br />
	<%= f.text_field :name, class: "form-control" %>
	<small id="emailHelp" class="form-text text-muted">Using your real name is optional. You should use something identifiable</small>
</div>

The whole file looks like this

<header>

  <div class="container" id="reg-box">
    <h2>Sign up</h2>
    <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
      <%= render "devise/shared/error_messages", resource: resource %>

      <div class="field form-group">
        <%= f.label :email %><br />
        <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %>
        <small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
      </div>

      <div class="field form-group">
        <%= f.label :password %>
        <% if @minimum_password_length %>
          <em>(<%= @minimum_password_length %> characters minimum)</em>
        <% end %><br />
        <%= f.password_field :password, autocomplete: "new-password", class: "form-control" %>
      </div>

      <div class="field form-group">
        <%= f.label :password_confirmation %><br />
        <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "form-control" %>
      </div>

      <div class="field form-group">
        <%= f.label :name %><br />
        <%= f.text_field :name, class: "form-control" %>
        <small id="emailHelp" class="form-text text-muted">Using your real name is optional. You should use something identifiable</small>
      </div>

      <div class="actions">
        <%= f.submit "Sign up", class: "btn btn-primary mb-2" %>
      </div>
    <% end %>

    <%= render "devise/shared/links" %>
  </div>
</header>

Edit app/views/devise/sessions/new.html.erb and do the same (don't add the name field here)

<header>
  <div class="container" id="reg-box">

    <h2>Log in</h2>

    <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
      <div class="field form-group">
        <%= f.label :email %><br />
        <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %>
      </div>

      <div class="field form-group">
        <%= f.label :password %><br />
        <%= f.password_field :password, autocomplete: "current-password", class: "form-control" %>
      </div>

      <% if devise_mapping.rememberable? %>
        <div class="field">
          <%= f.check_box :remember_me %>
          <%= f.label :remember_me %>
        </div>
      <% end %>

      <div class="actions">
        <%= f.submit "Log in", class: "btn btn-primary mb-2" %>
      </div>
    <% end %>

    <%= render "devise/shared/links" %>

  </div>
</header>

Note that we could have added the name field when we generated the model. But we would still have to configure the parameters and add the bootstrap stuff.

While we're at it. Let's modify the home page to show the name of the app and show two buttons for sign in and register.

Edit app/views/static_page/home.html.haml

%header
  .jumbotron.jumbotron-fluid
    .container
      %h1.display-4 Book of Secret Words
      %p.lead Get anonymous feedback from people around you
      %hr.my-4
      =link_to "Register", new_user_registration_path, class: "btn btn-primary btn-lg"
      =link_to "Log in", new_user_session_path, class: "btn btn-lg"

Last thing about users. We have not added the uid to users. Ideally we want the link for writing to a user to be in this format base-url/write?user=random_uid. We could have used the id that is generated automatically, but that is just a plain integer. So someone could just enumerate all the user ids and get the links. Not good.

Fortunately this is easy. Edit app/models/user.rb and add :uid to the devise method

devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :confirmable, :uid

Now we generate a migration

rails g migration add_uid_to_users

and edit the migration

class AddUidToUser < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :uid, :string
    add_index :users, :uid, :unique => true
  end
end

Finally migrate

rails db:migrate

Again, we could have done this while generating users, but in my case, the first version did not have this feature. So the linked repo follows this structure.

Now the time has come to add the mechanism of adding feedback or words as is called here. Yay!

We generate it as a scaffold. It references a User which indicates to whom it is said and a body which is just the content.

rails g scaffold Word user:references body:text

and migrate

rails db:migrate

That generates a whole bunch of stuff, most of which we will not need. Specifically we will only need to create words. We won't edit or delete. The default scaffold generation adds all of that (with codes). So we will first edit config/routes.rb to tell rails to have only path for new and create.

Find the line containing resources :words and make it into this

resources :words, only: [:new, :create]

This tells rails to turn off all the other paths.

Now the path to create a new word looks like localhost:3000/words/new. We change it to be localhost:3000/write

So edit the config/routes.rb and add

get 'write', to: 'words#new'

The final file looks like this

Rails.application.routes.draw do
  resources :words, only: [:new, :create]
  devise_for :users
  root 'static_page#home'
  get 'dashboard', to: 'static_page#dashboard'
  get 'write', to: 'words#new'
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

We're halfway there!

Let's focus on WordController. It has lots and lots of actions, but we only care about new and create

Let's do new first. This one's easy. Our URL for writing looks like /write?user=abcd where abcd is the uid. So we create an empty word, get the user corresponding to the uid and let rails handle the rest. If we can't find such user, we show the 404 page

# GET /words/new
  def new
    @user = User.find_by_uid params[:user]
    if @user
      @word = Word.new
      @word.user = @user
    else
      render :file => "#{Rails.root}/public/404", :layout => false, :status => :not_found
    end

  end

The action for create is a little involved. Once we have the word ready, we save it. If save succeeds, we queue a mail to the recipient and redirect the visitor to the signup page. If the save fails we just render the new page

def create
    @word = Word.new(word_params)

    respond_to do |format|
      if @word.save
        WordMailer.with(word: @word).word_mail.deliver_later
        format.html { redirect_to new_user_registration_path,
                                  notice: 'You have successfully said your words. Why not get your own account?' }
      else
        format.html { render :new }

      end
    end
  end

Note that we are just ignoring JSON request.

Now let's quickly configure the mailer. First we generate a mailer

rails g mailer WordMailer

Edit the mailer at app/mailer/word_mailer.rb

class WordMailer < ApplicationMailer
  def word_mail
    @word = params[:word]
    mail(to: @word.user.email, subject: "Somebody said something about you")
  end
end

Here when we call the word_mail method, we take the word, and send it to the email of the recipient of the word with the proper subject.

Finally edit app/views/word_mailer/word_mail.html.haml with this

!!!
%html
  %head
    %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
  %body
    %h1
      Somebody has said something about you
    %p
      Anonymous says,
      %br/
      %span=@word.body

We're just saying Anonymous says and then pasting the body of the word. And for the text version, edit app/views/word_mailer/word_mail.text.erb

Somebody has said something about you
==========================================
Anonymous says,
<%= @word.body %>

Mailer is done. We're rocking.

Now let's quickly add the relation between user and word. One user will have many words and one word will belong to one user.

Edit app/models/user.rb and add has_many :words . The final file looks like this -

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :confirmable, :uid

  has_many :words
  validates_presence_of :name
end

Note that I have added validates_presence_of :name so that we don't create a user with no name!

Edit app/models/word.rb and add belongs_to :user (if not already added during the creation of the model) and validates_presence_of :body

class Word < ApplicationRecord
  belongs_to :user
  validates_presence_of :body
end

Now we can do user.words to get a list of words for that user and write word.user to get the recipient of a word. Neat!

Now let's add bootstrap to the word creation form. Edit app/views/words/_form.html.erb and add the appropriate classes like before

<header>

  <div class="container" id="reg-box">
    <h1>Say something about <%= @word.user.name %></h1>
    <%= form_with(model: word, local: true) do |form| %>
      <% if word.errors.any? %>
        <div id="error_explanation">
          <h2><%= pluralize(word.errors.count, "error") %> prohibited this word from being saved:</h2>

          <ul>
            <% word.errors.full_messages.each do |message| %>
              <li><%= message %></li>
            <% end %>
          </ul>
        </div>
      <% end %>

      <div class="field">
        <%= form.text_field :user_id, hidden: true %>
      </div>
      <div class="field form-group">
        <%= form.text_area :body, placeholder: "Say your mind", class: "form-control" %>
      </div>

      <div class="actions">
        <%= form.submit "Say", class: "btn btn-primary mb-2" %>
      </div>
    <% end %>
  </div>
</header>

We are so close!

Now we modify the dashboard action to show the list of words for a user. Open up app/views/static_page/dashboard.html.haml.

So first we will get the logged in user. Remember we told the controller to redirect to home if not logged in? So we always have a logged in user. We can call current_user to get the current user. Then if his words array is empty, we show a message, otherwise we loop through the array and render a partial

- if current_user.words.empty?
    .container
      %span Oops, looks like nobody has said anything yet. Have you shared your URL?
  -current_user.words.each do |word|
    %p
      = render partial: "partials/word", locals: { word: word }

Let's create the partial in app/views/partials/_word.html.haml

%div
  %span
    %strong
      Anonymous
    says on
    %strong
      = word.created_at.localtime

  %hr
  %p.word-p
    = word.body

Nothing too complicated. We just Say Anonymous says on  [time] and then a horizontal line and then the body.

In the dashboard page, we render this partial by passing in the word. The full dashboard page looks like this

%nav.navbar.navbar-dark.bg-dark
  %span.navbar-brand.mb-0.h1 Book Of Secret Words
  = link_to "Log out", destroy_user_session_path, method: :delete, class: "btn btn-danger"
.container#share
  %h2 Share your URL:
  %span#url= "#{root_url}write?user=#{current_user.uid}"
  %button.btn.btn-primary#urlbtn Copy
.container
  %h2 Here's what the world has to say about you -
  - if current_user.words.empty?
    .container
      %span Oops, looks like nobody has said anything yet. Have you shared your URL?
  -current_user.words.each do |word|
    %p
      = render partial: "partials/word", locals: { word: word }

We have added a navbar that shows a logout button. And a div which shows the link "#{root_url}write?user=#{current_user.uid}". We have also added appropriate bootstrap classes.

Let's now make the copy button functional. Edit app/assets/javascripts/static_page.coffee

$ ->
  $("#urlbtn").on('click', (e) ->
    e.preventDefault()
    console.log($("#url").text())
    $temp = $("<input>")
    $("body").append($temp)
    $temp.val($("#url").text()).select()
    document.execCommand("copy")
    $temp.remove()
    $.notify({
      message:  "Copied"
    })
  )

Here when the copy button is clicked we are creating a hidden input element whose value is the url and then selecting it and copying. Finally we remove the input and notify. Kinda hacky but if there is some better way, let me know.

And that's it! Now you should be able to fire up the server, and register and write to the user, and also see the mail in mailcatcher.

Finally, let's make it production ready and dockerize.

I'll assume Docker and Docker compose are ready to go in your computer.

First create a Dockerfile in the root of the project.

FROM ruby:2.6.5
ENV RAILS_ENV production
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs && mkdir /app
WORKDIR /app
COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock
RUN bundle install --deployment --without development test
COPY . /app
RUN bundle exec rake assets:precompile
EXPOSE 3000
ENTRYPOINT ["./entrypoint.sh"]

Pretty basic stuff.

  1. We get the Ruby 2.6.5 image.
  2. We set the environment to production
  3. Install node and create a directory called app
  4. Set app as work directory.
  5. Copy Gemfile to app/
  6. Copy Gemfile.lock to app/ (We copy these two separately so that any change in the code does not trigger an unnecessary bundle install again)
  7. We run bundle install and don't install the gems that are only for development and test.
  8. Then we copy rest of the app.
  9. We precompile the assets.
  10. We expose the 3000 port
  11. We run the entrypoint.

Let's create the entrypoint.sh in the same folder

#!/bin/bash
rm -f /app/tmp/pids/server.pid
# Start the server
bundle exec puma -C config/puma.rb

It deletes a pid file which causes errors in restarting the server then starts puma. Make this file executable

chmod +x entrypoint.sh

Let's create a docker-compose.yml where we will tie the postgres with our rails app

version: '3'
services:
  mail:
    image: fgribreau/smtp-to-sendgrid-gateway
    environment:
      SENDGRID_API: ${SENDGRID_API}
  db:
    image: postgres
    restart: always
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - ./tmp/db:/var/lib/postgresql/data
  web:
    build: .
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      RAILS_MASTER_KEY: ${SECRET_TOKEN}
      RAILS_ENV: production
      RAILS_LOG_TO_STDOUT: 1
      ROOT_URL: ${ROOT_URL}
    ports:
      - "3000:3000"
    links:
      - db
      - mail
    depends_on:
      - db
      - mail

I'll briefly explain. We have three services:

  1. mail: Which is just a gateway for converting SMTP to Sendgrid requests. I'm using Sendgrid, but the app will make requests using SMTP. This service will do the appropriate conversion
  2. db: This is the postgres database.
  3. web: This is our app.

We need to make some final adjustments before we can deploy.

Edit config/environments/production.rb. Make sure config.serve_static_assets is set to true. Then add the following before the closing end

config.action_mailer.default_url_options = { host: ENV.fetch("ROOT_URL") {'<your-production-url'}, port: 80 }
  config.action_mailer.smtp_settings = {
      address: "mail",
      openssl_verify_mode: "none"
  }

  config.action_controller.default_url_options = {
      :host => ENV.fetch("ROOT_URL") {'<your-production-url>'}
  }

Here we are fetching the environment variable ROOT_URL which will be the URL of our app (in my case bookofsecretwords.abhattacharyea.dev) and if not found fallback to a predefined value (replace with your own).

Let's configure the database for production. Add the following to config/database.yml

production:
 adapter: postgresql
 host: db
 username: postgres
 password: <%= ENV["POSTGRES_PASSWORD"] %>

Here's the entire file

# PostgreSQL. Versions 9.1 and up are supported.
#
# Install the pg driver:
#   gem install pg
# On OS X with Homebrew:
#   gem install pg -- --with-pg-config=/usr/local/bin/pg_config
# On OS X with MacPorts:
#   gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config
# On Windows:
#   gem install pg
#       Choose the win32 build.
#       Install PostgreSQL and put its /bin directory on your path.
#
# Configure Using Gemfile
# gem 'pg'
#
default: &default
  adapter: postgresql
  encoding: unicode
  host: db
  username: postgres
  password: <%= ENV["POSTGRES_PASSWORD"] %>
  # For details on connection pooling, see Rails configuration guide
  # http://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  <<: *default
  database: BookOfSecretWords_development

  # The specified database role being used to connect to postgres.
  # To create additional roles in postgres see `$ createuser --help`.
  # When left blank, postgres will use the default role. This is
  # the same name as the operating system user that initialized the database.
  username: postgres

  # The password associated with the postgres role (username).
  #password:

  # Connect on a TCP socket. Omitted by default since the client uses a
  # domain socket that doesn't need configuration. Windows does not have
  # domain sockets, so uncomment these lines.
  #host: localhost

  # The TCP port the server listens on. Defaults to 5432.
  # If your server runs on a different port number, change accordingly.
  #port: 5432

  # Schema search path. The server defaults to $user,public
  #schema_search_path: myapp,sharedapp,public

  # Minimum log levels, in increasing order:
  #   debug5, debug4, debug3, debug2, debug1,
  #   log, notice, warning, error, fatal, and panic
  # Defaults to warning.
  #min_messages: notice

# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: BookOfSecretWords_test
  username: postgres
# As with config/secrets.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
# ever seen by anyone, they now have access to your database.
#
# Instead, provide the password as a unix environment variable when you boot
# the app. Read http://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full rundown on how to provide these environment variables in a
# production deployment.
#
# On Heroku and other platform providers, you may have a full connection URL
# available as an environment variable. For example:
#
#   DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
#
# You can use this database configuration with:
#
production:
 adapter: postgresql
 host: db
 username: postgres
 password: <%= ENV["POSTGRES_PASSWORD"] %>

Finally, if you want logs, add or uncomment this

  if ENV["RAILS_LOG_TO_STDOUT"].present?
    logger           = ActiveSupport::Logger.new(STDOUT)
    logger.formatter = config.log_formatter
    config.logger    = ActiveSupport::TaggedLogging.new(logger)
  end

Here's the entire file for me

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # Code is not reloaded between requests.
  config.cache_classes = true

  # Eager load code on boot. This eager loads most of Rails and
  # your application in memory, allowing both threaded web servers
  # and those relying on copy on write to perform better.
  # Rake tasks automatically ignore this option for performance.
  config.eager_load = true

  # Full error reports are disabled and caching is turned on.
  config.consider_all_requests_local       = false
  config.action_controller.perform_caching = true

  # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
  # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
  # config.require_master_key = true

  # Disable serving static files from the `/public` folder by default since
  # Apache or NGINX already handles this.
  config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?

  # Compress JavaScripts and CSS.
  config.assets.js_compressor = :uglifier
  # config.assets.css_compressor = :sass

  # Do not fallback to assets pipeline if a precompiled asset is missed.
  config.assets.compile = true
  config.serve_static_assets = true

  # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  # config.action_controller.asset_host = 'http://assets.example.com'

  # Specifies the header that your server uses for sending files.
  # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
  # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX

  # Store uploaded files on the local file system (see config/storage.yml for options)
  config.active_storage.service = :local

  # Mount Action Cable outside main process or domain
  # config.action_cable.mount_path = nil
  # config.action_cable.url = 'wss://example.com/cable'
  # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]

  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
  # config.force_ssl = true

  # Use the lowest log level to ensure availability of diagnostic information
  # when problems arise.
  config.log_level = :debug

  # Prepend all log lines with the following tags.
  config.log_tags = [ :request_id ]

  # Use a different cache store in production.
  # config.cache_store = :mem_cache_store

  # Use a real queuing backend for Active Job (and separate queues per environment)
  # config.active_job.queue_adapter     = :resque
  # config.active_job.queue_name_prefix = "BookOfSecretWords_#{Rails.env}"

  config.action_mailer.perform_caching = false

  # Ignore bad email addresses and do not raise email delivery errors.
  # Set this to true and configure the email server for immediate delivery to raise delivery errors.
  # config.action_mailer.raise_delivery_errors = false

  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
  # the I18n.default_locale when a translation cannot be found).
  config.i18n.fallbacks = true

  # Send deprecation notices to registered listeners.
  config.active_support.deprecation = :notify

  # Use default logging formatter so that PID and timestamp are not suppressed.
  config.log_formatter = ::Logger::Formatter.new

  # Use a different logger for distributed setups.
  # require 'syslog/logger'
  # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')

  if ENV["RAILS_LOG_TO_STDOUT"].present?
    logger           = ActiveSupport::Logger.new(STDOUT)
    logger.formatter = config.log_formatter
    config.logger    = ActiveSupport::TaggedLogging.new(logger)
  end

  # Do not dump schema after migrations.
  config.active_record.dump_schema_after_migration = false
  config.action_mailer.default_url_options = { host: ENV.fetch("ROOT_URL") {'bookofsecretwords.abhattacharyea.dev'}, port: 80 }
  config.action_mailer.smtp_settings = {
      address: "mail",
      openssl_verify_mode: "none"
  }
  # config.action_mailer.delivery_method = :sendmail

  config.action_controller.default_url_options = {
      :host => ENV.fetch("ROOT_URL") {'bookofsecretwords.abhattacharyea.dev'}
  }
end

One more small step that you have to do - setting up the environment variables.

First run

rails credentials:edit

This should open up the editor with the credentials file. We are not interested in the file. But it will create a config/master.key file (which is auto added to .gitignore) and a `config/credentials.yml.enc).

At this point your code is ready. So let's deploy. Assuming you are now inside your server and have your code ready, create a file called .env in the project directory

COMPOSE_PROJECT_NAME=book_of_secret_words
SENDGRID_API=<insert your api key>
POSTGRES_PASSWORD=<password of database>
SECRET_TOKEN=<The master key>
ROOT_URL=<your url>

Replace the appropriate values. For the master key, you have to get the content of config/master.key. Do not put it in git. The POSTGRES_PASSWORD is the password of the database that will be created.

Now just run

docker-compose up

And your server should be up and running in port 3000.

Note that if you are in GCP, it block incoming traffic at port 3000. So you have to either open that port or do some kind of reverse proxy in port 80.

Here is the repo https://gitlab.com/herald_of_solace/book-of-secret-words

You'll see there is an .env file in the repo. That's just an example. Don't worry, they're not used in production ;-)

Hopefully that is enough. Let me know if I missed something. Also, make sure to follow me on LinkedIn.

Aniket Bhattacharyea

Aniket Bhattacharyea