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 -
- Devise: Handle authentications. We will not make our own authentication mechanism. Instead we will use Devise like any responsible citizen.
- Devise UID: We will use this gem to add a unique random ID to each user, which we will use to generate the link.
- HAML: HTML is just so old school
- Bootstrap: For responsive design.
- JQuery: We're not going to write a lot of javascript (except the copy button). Still it's a good idea to use JQuery.
- 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 -
- home - This will show the starting page to the User and if he is logged in, he will be redirected to dashboard.
- 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 dashboard
action should redirect to home if the user has not confirmed email yet. So edit the dashboard
action 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.
- We get the Ruby 2.6.5 image.
- We set the environment to production
- Install node and create a directory called app
- Set
app
as work directory. - Copy
Gemfile
toapp/
- Copy
Gemfile.lock
toapp/
(We copy these two separately so that any change in the code does not trigger an unnecessary bundle install again) - We run
bundle install
and don't install the gems that are only for development and test. - Then we copy rest of the app.
- We precompile the assets.
- We expose the 3000 port
- 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:
- 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
- db: This is the postgres database.
- 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.