In this post, I explore creating a new cookieless pipeline for the Phoenix HTML.
I want to be able to cache HTML for Varnish or Cloudflare. Varnish (and Cloudflare) wont cache HTML (or other assets) if they have cookies. Varnish is designed to have conservative defaults, and caching cookies is VERY BAD, its a security and privacy risk, as cookies are the primary method for the authentication and personalization, think shopping cart, online.
Version check
Install latest Phoenix on www.Ubuntu, using the Installation official docs.
I am running OK versions and don't need to upgrade.
niccolox@niccolox-xps:~/Projects/Devekko$ mix www.hex
Found existing entry: /home/niccolox/.asdf/installs/elixir/1.13.3-otp-24/.mix/archives/hex-1.0.1
Are you sure you want to replace it with "https://repo.hex.pm/installs/1.13.0/hex-1.0.1.ez"? [Yn] n
niccolox@niccolox-xps:~/Projects/Devekko$ elixir -v
Erlang/OTP 24 [erts-12.3.1] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit]
Elixir 1.13.3 (compiled with Erlang/OTP 24)
niccolox@niccolox-xps:~/Projects/Devekko$ erl -v
Erlang/OTP 24 [erts-12.3.1] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit]
Eshell V12.3.1 (abort with ^G)
1>
BREAK: (a)bort (A)bort with dump (c)ontinue (p)roc info (i)nfo
(l)oaded (v)ersion (k)ill (D)b-tables (d)istribution
Install Latest Phoenix Generator
niccolox@niccolox-xps:~/Projects/Devekko$ mix archive.install hex phx_new
Resolving Hex dependencies...
Dependency resolution completed:
New:
phx_new 1.6.12
* Getting phx_new (Hex package)
All dependencies are up to date
Compiling 11 files (.ex)
Generated phx_new app
Generated archive "phx_new-1.6.12.ez" with MIX_ENV=prod
Are you sure you want to install "phx_new-1.6.12.ez"? [Yn] Y
* creating /home/niccolox/.asdf/installs/elixir/1.13.3-otp-24/.mix/archives/phx_new-1.6.12
Create Phoenix App
I am creating a Phoenix app to share on Github for peer review.
niccolox@niccolox-xps:~/Projects/Devekko$ mix archive.install hex phx_new
Resolving Hex dependencies...
Dependency resolution completed:
New:
phx_new 1.6.12
* Getting phx_new (Hex package)
All dependencies are up to date
Compiling 11 files (.ex)
Generated phx_new app
Generated archive "phx_new-1.6.12.ez" with MIX_ENV=prod
Are you sure you want to install "phx_new-1.6.12.ez"? [Yn] Y
* creating /home/niccolox/.asdf/installs/elixir/1.13.3-otp-24/.mix/archives/phx_new-1.6.12
niccolox@niccolox-xps:~/Projects/Devekko$ mix phx.new phx_html_cookieless
* creating phx_html_cookieless/config/config.exs
* creating phx_html_cookieless/config/dev.exs
* creating phx_html_cookieless/config/prod.exs
* creating phx_html_cookieless/config/runtime.exs
* creating phx_html_cookieless/config/test.exs
* creating phx_html_cookieless/lib/phx_html_cookieless/application.ex
* creating phx_html_cookieless/lib/phx_html_cookieless.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/error_helpers.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/error_view.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/endpoint.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/router.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/telemetry.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web.ex
* creating phx_html_cookieless/mix.exs
* creating phx_html_cookieless/README.md
* creating phx_html_cookieless/.formatter.exs
* creating phx_html_cookieless/.gitignore
* creating phx_html_cookieless/test/support/conn_case.ex
* creating phx_html_cookieless/test/test_helper.exs
* creating phx_html_cookieless/test/phx_html_cookieless_web/views/error_view_test.exs
* creating phx_html_cookieless/lib/phx_html_cookieless/repo.ex
* creating phx_html_cookieless/priv/repo/migrations/.formatter.exs
* creating phx_html_cookieless/priv/repo/seeds.exs
* creating phx_html_cookieless/test/support/data_case.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/controllers/page_controller.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/page_view.ex
* creating phx_html_cookieless/test/phx_html_cookieless_web/controllers/page_controller_test.exs
* creating phx_html_cookieless/test/phx_html_cookieless_web/views/page_view_test.exs
* creating phx_html_cookieless/assets/vendor/topbar.js
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/layout/root.html.heex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/layout/app.html.heex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/layout/live.html.heex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/layout_view.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/page/index.html.heex
* creating phx_html_cookieless/test/phx_html_cookieless_web/views/layout_view_test.exs
* creating phx_html_cookieless/lib/phx_html_cookieless/mailer.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/gettext.ex
* creating phx_html_cookieless/priv/gettext/en/LC_MESSAGES/errors.po
* creating phx_html_cookieless/priv/gettext/errors.pot
* creating phx_html_cookieless/assets/css/phoenix.css
* creating phx_html_cookieless/assets/css/app.css
* creating phx_html_cookieless/assets/js/app.js
* creating phx_html_cookieless/priv/static/robots.txt
* creating phx_html_cookieless/priv/static/images/phoenix.png
* creating phx_html_cookieless/priv/static/favicon.ico
Fetch and install dependencies? [Yn] Y
* running mix deps.get
* running mix deps.compile
Create the Postgres DB
niccolox@niccolox-xps:~/Projects/Devekko$ cd phx_html_cookieless
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ mix ecto.create
warning: the :gettext compiler is no longer required in your mix.exs.
Please find the following line in your mix.exs and remove the :gettext entry:
compilers: [..., :gettext, ...] ++ Mix.compilers(),
(gettext 0.20.0) lib/mix/tasks/compile.gettext.ex:5: Mix.Tasks.Compile.Gettext.run/1
(mix 1.13.3) lib/mix/task.ex:397: anonymous fn/3 in Mix.Task.run_task/3
(mix 1.13.3) lib/mix/tasks/compile.all.ex:92: Mix.Tasks.Compile.All.run_compiler/2
(mix 1.13.3) lib/mix/tasks/compile.all.ex:72: Mix.Tasks.Compile.All.compile/4
(mix 1.13.3) lib/mix/tasks/compile.all.ex:59: Mix.Tasks.Compile.All.with_logger_app/2
(mix 1.13.3) lib/mix/tasks/compile.all.ex:36: Mix.Tasks.Compile.All.run/1
Compiling 14 files (.ex)
Generated phx_html_cookieless app
The database for PhxHtmlCookieless.Repo has been created
We get the app up and running
MIX_ENV=dev iex -S mix phx.server
Curl the app and see the cookie
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ curl -I http://www.ost:4000
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 2358
content-type: text/html; charset=utf-8
cross-origin-window-policy: deny
date: Sat, 10 Sep 2022 19:44:50 GMT
server: Cowboy
x-content-type-options: nosniff
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-request-id: FxOXV76Mi34Gm5UAAADC
x-xss-protection: 1; mode=block
set-cookie: _phx_html_cookieless_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYaDZsZk9MZm14ejJ1bDBfWlphQmxXOTdr.FkqSdNU9VpLGE2LBl4_ElaXvrA_8TX0UEcC3KhN4zu4; path=/; HttpOnly
Initiate the Git repo on Github
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git status
fatal: not a git repository (or any of the parent directories): .git
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git init
Initialized empty Git repository in /home/niccolox/Projects/Devekko/phx_html_cookieless/.git/
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git add .
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git commit -am "How to generate Sessionless, Cookie-Free Cacheable HTML"
[master (root-commit) 9b94e77] How to generate Sessionless, Cookie-Free Cacheable HTML
46 files changed, 1764 insertions(+)
create mode 100644 .formatter.exs
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 assets/css/app.css
create mode 100644 assets/css/phoenix.css
create mode 100644 assets/js/app.js
create mode 100644 assets/vendor/topbar.js
create mode 100644 config/config.exs
create mode 100644 config/dev.exs
create mode 100644 config/prod.exs
create mode 100644 config/runtime.exs
create mode 100644 config/test.exs
create mode 100644 lib/phx_html_cookieless.ex
create mode 100644 lib/phx_html_cookieless/application.ex
create mode 100644 lib/phx_html_cookieless/mailer.ex
create mode 100644 lib/phx_html_cookieless/repo.ex
create mode 100644 lib/phx_html_cookieless_web.ex
create mode 100644 lib/phx_html_cookieless_web/controllers/page_controller.ex
create mode 100644 lib/phx_html_cookieless_web/endpoint.ex
create mode 100644 lib/phx_html_cookieless_web/gettext.ex
create mode 100644 lib/phx_html_cookieless_web/router.ex
create mode 100644 lib/phx_html_cookieless_web/telemetry.ex
create mode 100644 lib/phx_html_cookieless_web/templates/layout/app.html.heex
create mode 100644 lib/phx_html_cookieless_web/templates/layout/live.html.heex
create mode 100644 lib/phx_html_cookieless_web/templates/layout/root.html.heex
create mode 100644 lib/phx_html_cookieless_web/templates/page/index.html.heex
create mode 100644 lib/phx_html_cookieless_web/views/error_helpers.ex
create mode 100644 lib/phx_html_cookieless_web/views/error_view.ex
create mode 100644 lib/phx_html_cookieless_web/views/layout_view.ex
create mode 100644 lib/phx_html_cookieless_web/views/page_view.ex
create mode 100644 mix.exs
create mode 100644 mix.lock
create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po
create mode 100644 priv/gettext/errors.pot
create mode 100644 priv/repo/migrations/.formatter.exs
create mode 100644 priv/repo/seeds.exs
create mode 100644 priv/static/favicon.ico
create mode 100644 priv/static/images/phoenix.png
create mode 100644 priv/static/robots.txt
create mode 100644 test/phx_html_cookieless_web/controllers/page_controller_test.exs
create mode 100644 test/phx_html_cookieless_web/views/error_view_test.exs
create mode 100644 test/phx_html_cookieless_web/views/layout_view_test.exs
create mode 100644 test/phx_html_cookieless_web/views/page_view_test.exs
create mode 100644 test/support/conn_case.ex
create mode 100644 test/support/data_case.ex
create mode 100644 test/test_helper.exs
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git branch -M main
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git remote add origin [email protected]:niccolox/phx_html_cookieless.git
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git push -u origin main
Enumerating objects: 74, done.
Counting objects: 100% (74/74), done.
Delta compression using up to 16 threads
Compressing objects: 100% (66/66), done.
Writing objects: 100% (74/74), 44.02 KiB | 8.80 MiB/s, done.
Total 74 (delta 0), reused 0 (delta 0)
To github.com:niccolox/phx_html_cookieless.git
* [new branch] main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'
Add new Cookieless endpoint to application
defmodule PhxHtmlCookieless.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
# Start the Ecto repository
PhxHtmlCookieless.Repo,
# Start the Telemetry supervisor
PhxHtmlCookielessWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: PhxHtmlCookieless.PubSub},
# Start the Endpoint (http/https)
PhxHtmlCookielessWeb.Endpoint,
PhxHtmlCookielessWeb.CookielessEndpoint
# Start a worker by calling: PhxHtmlCookieless.Worker.start_link(arg)
# {PhxHtmlCookieless.Worker, arg}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: PhxHtmlCookieless.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
PhxHtmlCookielessWeb.Endpoint.config_change(changed, removed)
:ok
end
end
Add a Cookieless view to definition file
def cookielessview do
quote do
use Phoenix.View,
root: "lib/phx_html_cookieless_web/templates",
namespace: PhxHtmlCookielessWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
Add a Cookieless Page controller
defmodule PhxHtmlCookielessWeb.CookielessPageController do
use PhxHtmlCookielessWeb, :controller
plug :put_layout, "cookielessapp.html"
def index(conn, _params) do
render(conn, "index.html")
end
end
Add a Cookieless endpoint with no session or flash
defmodule PhxHtmlCookielessWeb.CookielessEndpoint do
use Phoenix.Endpoint, otp_app: :phx_html_cookieless
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :phx_html_cookieless
end
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.MethodOverride
plug Plug.Head
plug PhxHtmlCookielessWeb.Router
end
Add a Cache Control Plug to add caching and cache if stale cache control for cookieless routes
defmodule PhxHtmlCookielessWeb.Plug.CacheControl do
@moduledoc """
Manages the adding of cache-control headers to public requests so CDN
can do some caching
"""
import Plug.Conn
def init(opts), do: opts
def call(conn = %{assigns: %{current_user: user}}, _opts) when not is_nil(user), do: conn
# https://developers.cloudflare.com/cache/about/cache-control/
# Cache assets with revalidation, but allow stale responses if origin server is unreachable
# https://developer.fastly.com/learning/concepts/cache-freshness/#surrogate-control
def call(conn, _opts) do
conn
|> put_resp_header("cache-control", "max-age=3600, stale-while-revalidate=60, stale-if-error=604800")
|> put_resp_header(
"surrogate-control",
"max-age=3600, stale-while-revalidate=60, stale-if-error=604800"
)
end
end
Adjust the router with a Cookieless sessionless pipeline and scope for the static, cookieless pathes
defmodule PhxHtmlCookielessWeb.Router do
use PhxHtmlCookielessWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {PhxHtmlCookielessWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :browserstatic do
plug :accepts, ["html"]
plug :put_root_layout, {PhxHtmlCookielessWeb.LayoutView, :rootcookieless}
plug PhxHtmlCookielessWeb.Plug.CacheControl
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", PhxHtmlCookielessWeb do
pipe_through :browser
get "/", PageController, :index
end
scope "/cookieless", PhxHtmlCookielessWeb do
pipe_through :browserstatic
get "/", CookielessPageController, :index
end
# Other scopes may use custom stacks.
# scope "/api", PhxHtmlCookielessWeb do
# pipe_through :api
# end
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through :browser
live_dashboard "/dashboard", metrics: PhxHtmlCookielessWeb.Telemetry
end
end
# Enables the Swoosh mailbox preview in development.
#
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end
Add a cookieless_page template folder and index file
<section class="phx-hero">
<h1><%= gettext "Welcome to %{name}!", name: "Cookieless Phoenix HTML" %></h1>
<p>Peace of mind from prototype to production</p>
</section>
Add a cookieless app root layout for the cookieless page controller
<main class="container">
<%= @inner_content %>
</main>
Add a layout for cookieless which has no CSRF, no sessions, no JS.
Obviously this is crude and not realistic for production, we probably need a second JS file with no CSRF.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= live_title_tag assigns[:page_title] || "PhxHtmlCookieless", suffix: " · Phoenix Framework" %>
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
</head>
<body>
<header>
<section class="container">
<nav>
</nav>
<a href="https://phoenixframework.org/" class="phx-logo">
<img src={Routes.static_path(@conn, "/images/phoenix.png")} alt="Phoenix Framework Logo"/>
</a>
</section>
</header>
<%= @inner_content %>
</body>
</html>
We create a cookieless page view which references our cookieless view definition, giving us no flash or session: view
defmodule PhxHtmlCookielessWeb.CookielessPageView do
use PhxHtmlCookielessWeb, :cookielessview
end
You can see all these commits on Github
Validate in cURL
Cookieless scope
Now, In curl we can see the cookieless cacheable /cookieless scope
curl -I http://www.ost:4000/cookieless
HTTP/1.1 200 OK
cache-control: max-age=3600, stale-while-revalidate=60, stale-if-error=604800
content-length: 905
content-type: text/html; charset=utf-8
date: Sat, 10 Sep 2022 21:13:40 GMT
server: Cowboy
surrogate-control: max-age=3600, stale-while-revalidate=60, stale-if-error=604800
x-request-id: FxOcMLOpAdCZttoAAAAK
Cookie scope
Vs the default homepage, which is not cacheable and has cookies and sessions.
curl -I http://www.ost:4000
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 2358
content-type: text/html; charset=utf-8
cross-origin-window-policy: deny
date: Sat, 10 Sep 2022 21:14:35 GMT
server: Cowboy
x-content-type-options: nosniff
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-request-id: FxOcPakeOME1JvIAAABK
x-xss-protection: 1; mode=block
set-cookie: _phx_html_cookieless_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYdGJSeE8zX1pDNktYejZDYWwyN2Z6YTVn.TrZZ8ympcUrnhHPyzCSzeDA1flhRUmr0Gqn9prYWKz0; path=/; HttpOnly
Conclusion
IF you are hosting a website with MANY pages, you want to be able to cache HTML in Varnish or Cloudflare or Fastly. You don't need authentication on landing pages.
More advanced techniques would be to route in the Phoenix app by Cookies. Say if, a user visited the cookieless page with a backend, authenticated user cookie, either the Phoenix default or a cookie from a cart, then the router switches the scope to or controller and personalizes the page.
These kinds of caching issues can save money, speed up the site and generally improve the UX.