Multitenant Phoenix application deployment with SSL using HAProxy
01 Aug 2018Table of Contents
- Installing HAProxy
- Phoenix-specific HAProxy configurations
- Configuring Phoenix Apps
- Adding TLS support via HAProxy
Recently I needed to deploy two Phoenix applications on a CentOS server that I was provided with. A normal way to do so would be to use subdomains, i.e. using appA.domain.com
and appB.domain.com
. However, in this case I was not able to configure the DNS records for the server. I had to thus resort to appending path segments to the domain, i.e. domain.com/appA
and domain.com/appB
, and rely on a reverse proxy to route the request to the corresponding app.
HAProxy
seemed an attractive option for the reverse proxy, as its configuration syntax is clean and straightforward. However, there is relatively few documentation for it compared with Nginx
, and it took me a while to figure everything out. I’d like to share my experience in this article.
The commands are tested on a fresh CentOS 7.5 VM.
Installing HAProxy
This section documents the steps to install the newest HAProxy and get it running. You may also just choose to install it using the package manager of your system and skip ahead to the next section after verifying the installation. Part of this section is based on this online guide.
The HAProxy version in the CentOS repo is not up to date. To install the newest version of HAProxy, first download the source code from http://www.haproxy.org/#down.
wget http://www.haproxy.org/download/1.8/src/haproxy-1.8.13.tar.gz
tar -xzvf haproxy-1.8.13.tar.gz
cd haproxy-1.8.13
You may first need to install the dependencies with
sudo yum install gcc pcre-static pcre-devel openssl openssl-devel
before the compilation.
You can then compile HAProxy with
sudo make TARGET=linux2628 USE_PCRE=1 USE_OPENSSL=1 USE_ZLIB=1
A few notes about the flags:
TARGET=linux2628
: The build targets Linux 2.6.28, 3.x, and above. Such kernel versions should be the ones in use by default nowadays in most Linux systems.USE_PCRE=1
: Uselibpcre
for regex processing.USE_OPENSSL=1
: This is important for adding SSL/TLS support. Otherwise we would not be able to let HAProxy use our own SSL certificates in the later configurations.USE_ZLIB=1
: Enable native support for zlib to benefit from HTTP compression.
After the compilation finishes, run
sudo make install
to install it.
Then, some initialization steps need to be performed:
sudo ln -s /usr/local/sbin/haproxy /usr/sbin/haproxy
sudo mkdir -p /etc/haproxy
sudo mkdir -p /var/lib/haproxy
# Useful if you want to enable the default stats page for HAProxy
sudo touch /var/lib/haproxy/stats
sudo cp ~/haproxy-1.8.13/examples/haproxy.init /etc/init.d/haproxy
sudo chmod 755 /etc/init.d/haproxy
sudo systemctl daemon-reload
sudo chkconfig haproxy on
sudo useradd -r haproxy
Before we are able to use HAProxy, we may also need to open up the ports 80 and 443 to outside traffic, if they aren’t open already. You can either do it by directly modifying iptables
, or use the firewall-cmd
frontend that comes with CentOS 7. The following is the direct iptables
command:
sudo iptables -A INPUT -p tcp -m multiport --dports 80,443 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
sudo systemctl reload iptables
Now we can add a basic configuration file with sudo vi /etc/haproxy/haproxy.cfg
:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats timeout 30s
user haproxy
group haproxy
daemon
maxconn 2048
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
option forwardfor
option http-server-close
frontend http_front
bind *:80
stats uri /haproxy?stats
default_backend http_back
backend http_back
balance roundrobin
server web1 127.0.0.1:80 check
We can then start up HAProxy with
sudo systemctl start haproxy
You should be able to see the HAProxy stats page at http://example.com/haproxy?stats. This means you have successfully installed HAProxy.
Phoenix-specific HAProxy configurations
Now that HAProxy runs normally, let’s edit the configuration file /etc/haproxy/haproxy.cfg
so that it is able to serve two different Phoenix apps, distinguishing them using path segments.
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats timeout 30s
user haproxy
group haproxy
daemon
maxconn 2048
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
option forwardfor
option http-server-close
frontend http_front
# We don't really need the stats page now.
# stats uri /haproxy?stats
bind *:80
acl appA_url url_beg /appA
acl appB_url url_beg /appB
use_backend appA if appA_url
use_backend appB if appB_url
backend appA
http-request set-path %[path,regsub(/appA/?,/)]
balance roundrobin
server web1 127.0.0.1:4001 check
backend appB
http-request set-path %[path,regsub(/appB/?,/)]
balance roundrobin
server web1 127.0.0.1:5001 check
acl appA_url url_beg /appA
creates an access control list calledappA_url
that is activated whenever the URL path after the domain name begins with/appA
.use_backend appA if appA_url
then tells HAProxy to select the backend calledappA
if the ACLappA_url
is activated.http-request set-path %[path,regsub(/appA/?,/)]
modifies the HTTP request so that the path segment/appA/
is replaced with/
before it goes through to the actual Phoenix app. This is important because we don’t want to modify therouter.ex
file of the app. That is to say, if you have
scope "/", App do
pipe_through :browser
get "/", PageController, :index
end
which expects to serve the homepage at http://example.com/, the router will still receive the incoming HTTP requests as if they were for http://example.com/ instead of for http://example.com/appA/ , and thus serve the pages correctly.
(We will also need to change a part of Phoenix configuration for this to work. This will be covered in the next section.)
server web1 127.0.0.1:4001 check
assumes thatappA
is being served at port 4001 of this machine. The relevant Phoenix configuration will be covered in the next section.
Don’t forget to restart HAProxy with sudo systemctl restart haproxy
after saving the changes.
Checking for errors in the HAProxy configuration file
N.B.: Sometimes the haproxy service might fail to start due to errors in `haproxy.cfg`. The following command checks for errors in the config file:
sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg
Configuring Phoenix Apps
Now we need to also configure the relevant Phoenix apps so that they work in conjunction with HAProxy. For this we need to modify App.Endpoint
in config/prod.exs
:
config :app, App.Endpoint,
url: [host: "example.com", path: "/appA"],
http: [port: 4001],
cache_static_manifest: "priv/static/cache_manifest.json"
-
Note how we added
path: "/appA"
under the:url
key. This is necessary, because we want the links automatically generated by Phoenix (e.g.http://example.com/appA/js/app-01302d06e5b1534d67ec820fde5c1292.js?vsn=d
) to still have the/appA
path segment in them, so that HAProxy can properly identify the HTTP requests as going to appA. After identifying the incoming requests, the ACLappA_url
will be activated and HAProxy will proceed to strip the path segment, before rerouting the HTTP request to theappA
backend.Think about it this way: If we don’t do this, the incoming HTTP requests by clients would directly ask for
example.com/js/app.js
instead ofexample.com/appA/js/app.js
. However, since HAProxy doesn’t have any ACL defined that matches the former case, it won’t know how to handle the request, and an error will be returned as the result. - We are using HTTP since HTTPS connection between the server and the client will be handled by HAProxy. Of course, it can still be a good practice to use HTTPS for the connection between HAProxy and Phoenix, especially if your app and HAProxy actually live on different machines.
- We manually specify
port: 4001
to match up with the port specified in the HAProxy configuration. You can also use{:system, "PORT"}
instead of4001
, and feed in the port as an environment variable when launching the app.
Needless to say, the same edits are to be performed on appB
, with the path as "/appB"
and the port as 5001
.
To ensure that the applications automatically start on boot, create a systemd service for each app in the folder /lib/systemd/system
:
app-a.service
:
[Unit]
Description=AppA
[Service]
Type=simple
User=username
Group=groupname
Restart=on-failure
Environment=MIX_ENV=prod "PORT=4001"
Environment=LANG=en_US.UTF-8
WorkingDirectory=/path/to/appA
ExecStart=/usr/local/bin/mix phoenix.server
[Install]
WantedBy=multi-user.target
app-b.service
:
[Unit]
Description=AppB
[Service]
Type=simple
User=username
Group=groupname
Restart=on-failure
Environment=MIX_ENV=prod "PORT=5001"
Environment=LANG=en_US.UTF-8
WorkingDirectory=/path/to/appB
ExecStart=/usr/local/bin/mix phoenix.server
[Install]
WantedBy=multi-user.target
and enable them with sudo systemctl enable app-a.service
sudo systemctl enable app-b.service
. Start them with systemctl start
.
If everything is set up correctly up to this point, you should now be able to access the applications at http://example.com/appA and http://example.com/appB.
(The systemd configuration is taken from an ElixirForum thread by yurko, where further details and discussions can be found.)
Working with Distillery
Instead of the plain
WorkingDirectory=/path/to/appB
ExecStart=/usr/local/bin/mix phoenix.server
we might want to use distillery to package our Phoenix apps. This also allows us to stop and restart the apps more easily. The corresponding systemd
config would be:
[Service]
Type=simple
User=user
Group=group
WorkingDirectory=/path/to/appB
ExecStart=/path/to/appB/_build/prod/rel/appB/bin/appB foreground
ExecStop=/path/to/appB/_build/prod/rel/appB/bin/appB stop
Environment=LANG=en_US.UTF-8
Environment=MIX_ENV=prod
Environment=RELEASE_MUTABLE_DIR=/path/to/appB/_build/prod/rel/appB/var/tmp
LimitNOFILE=65536
UMask=0027
SyslogIdentifier=appB
Restart=always
RestartSec=5
Adding TLS support via HAProxy
Normally we’d want to serve our apps via HTTPS, which HAProxy also supports.
First, we need to concatenate all the related keys/certs (.pem
files) into one. The order required by HAProxy is:
- server private key (without any password)
- server certificate
- intermediate certificate 1
- intermediate certificate 2
- Root trusted authority certificate (if any)
For example, if your cert files are stored under /etc/pki/tls/certs/
, you would run
DOMAIN='example.com' sudo -E bash -c 'cat /etc/pki/tls/private/key_example.pem /etc/pki/tls/certs/cert_example.pem /etc/pki/tls/certs/chain.pem > /etc/haproxy/certs/$DOMAIN.pem'
If you use letsencrypt
, it’s likely that you already have a fullchain.pem
that you can use directly.
DOMAIN='example.com' sudo -E bash -c 'cat /etc/letsencrypt/live/privkey.pem /etc/letsencrypt/live/fullchain.pem > /etc/haproxy/certs/$DOMAIN.pem'
There is also a letsencrypt plugin for HAProxy, though I haven’t used it myself.
Then, we need to modify /etc/haproxy/haproxy.cfg
again:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats timeout 30s
user haproxy
group haproxy
daemon
maxconn 2048
# TLS
tune.ssl.default-dh-param 2048
ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS
# Disable SSL v3, which is insecure
ssl-default-bind-options no-sslv3
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
option forwardfor
option http-server-close
frontend http_front
bind *:80
http-request add-header X-Forwarded-Proto http
acl appA_url url_beg /appA
acl appB_url url_beg /appB
use_backend appA if appA_url
use_backend appB if appB_url
frontend https_front
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
http-request add-header X-Forwarded-Proto https
acl appA_url url_beg /appA
acl appB_url url_beg /appB
use_backend appA if appA_url
use_backend appB if appB_url
backend appA
http-request redirect scheme https if ! { ssl_fc }
http-request set-path %[path,regsub(/appA/?,/)]
balance roundrobin
server web1 127.0.0.1:4001 check
backend appB
http-request redirect scheme https if ! { ssl_fc }
http-request set-path %[path,regsub(/appB/?,/)]
balance roundrobin
server web1 127.0.0.1:5001 check
- We added some TLS-specific configurations under
global
, after the comment# TLS
- We added a
HTTPS
frontend. It binds to port 443, and uses the concatenated certificate file that we just produced in the previous step. - In the backends, we added a
http-request redirect
directive to make sure that even if a user visited the website via HTTP (port 80), they will still be redirected to the HTTPS site instead.
Restart haproxy with sudo systemctl restart haproxy
. Now, the apps should be up and running under https://example.com/appA and https://example.com/appB.
(Here is an alternative configuration for the frontend and backend portions):
frontend http_front
bind *:80
http-request add-header X-Forwarded-Proto http
http-request redirect scheme https
frontend https_front
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
http-request add-header X-Forwarded-Proto https
acl appA_url url_beg /appA
acl appB_url url_beg /appB
use_backend appA if appA_url
use_backend appB if appB_url
backend appA
http-request set-path %[path,regsub(/appA/?,/)]
balance roundrobin
server web1 127.0.0.1:4001 check
backend appB
http-request set-path %[path,regsub(/appB/?,/)]
balance roundrobin
server web1 127.0.0.1:5001 check