Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

STATUS: FULLY FUNCTIONAL

Ruby on Rails/Puma:

httpd:


Next-generation server setup—hosting static websites and apps simultaneously on OpenBSD

Rated A+ at SSL Labs and Security Headers

2020-06-19

A simpler and more secure alternative to Linux/NGiNX/Apache/Passenger for hosting static sites and Ruby/Python/Node type web apps on the same server at the same time. This example uses OpenBSD's relayd to direct traffic to OpenBSD's httpd for static sites, and Ruby on Rails/Puma for web apps.

Choose OpenBSD for your Unix needs. OpenBSD -- the world's simplest and most secure Unix-like OS. Creator of the NGiNX/Apache webserver replacement httpd, the OpenSSL crypto stack replacement LibreSSL, the iptables/nftables firewall replacement pf, the Postfix mail server replacement OpenSMTPD, OpenSSH and much, much more. OpenBSD -- the cleanest kernel, the cleanest userland and the cleanest configuration syntax.

Files in this setup:

/etc/relayd.conf
/etc/httpd.conf
/etc/acme-client.conf
/etc/rc.d/myapp1
/etc/rc.d/__helper
/home/myapp1/myapp1/config/puma.rb
/home/myapp1/myapp1/config/environments/production.rb

Generate certificates and make them readable to apps in the wheel group:

acme-client -v mystaticsite.com
acme-client -v myapp1.com
acme-client -v myapp2.com
chmod 750 /etc/ssl/private

Add crontabs for automated renewal:

crontab -e
0 * * * * sleep $((RANDOM \% 2048)) && acme-client mystaticsite.com && rcctl restart relayd
0 * * * * sleep $((RANDOM \% 2048)) && acme-client myapp1.com && rcctl restart myapp1
0 * * * * sleep $((RANDOM \% 2048)) && acme-client myapp2.com && rcctl restart myapp2

/etc/relayd.conf

public="vio0"
private="lo0"

table <acme_challenge> { $private }
acme_challenge_port="5000"

table <httpd_https_redirect> { $private }
httpd_https_redirect_port="6000"

table <httpd_static_sites> { $private }
httpd_static_sites_port="7000"

table <rails1> { $private }
rails1_ssl_port="8000"
table <rails2> { $private }
rails2_ssl_port="8002"

# Protocols

http protocol "http" {
  # ACME-compliant HTTP-01 challenge
  # https://letsencrypt.org/docs/challenge-types/
  pass request quick path "/.well-known/acme-challenge/*" forward to <acme_challenge>

  # Filter out Rails apps by domain name
  # https://unix.stackexchange.com/questions/466232/openbsd-relay-https-proxy-for-multiple-domains
  pass request header "Host" value "myapp1.com" forward to <rails1>
  pass request header "Host" value "www.myapp1.com" forward to <rails1>
  
  pass request header "Host" value "myapp2.com" forward to <rails2>
  pass request header "Host" value "www.myapp2.com" forward to <rails2>
}

http protocol "https_reverse_proxy" {
  # Block all by default
  block

  # Start opening up
  pass request header "Host" value "mystaticsite.com" forward to <httpd_static_sites>
  pass request header "Host" value "www.mystaticsite.com" forward to <httpd_static_sites>
  
  pass request header "Host" value "myapp1.com" forward to <rails1>
  pass request header "Host" value "www.myapp1.com" forward to <rails1>
  
  pass request header "Host" value "myapp2.com" forward to <rails2>
  pass request header "Host" value "www.myapp2.com" forward to <rails2>

  tls keypair "mystaticsite"
  tls keypair "myapp1"
  tls keypair "myapp2"
  
  # Pass address headers
  match header set "X-Client-IP" value "$REMOTE_ADDR:$REMOTE_PORT"
  match header set "X-Forwarded-For" value "$REMOTE_ADDR"
  match header set "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT"

  # Best practice security headers
  # https://securityheaders.com
  # https://www.marksayson.com/blog/setting_http_security_headers_in_rails/
  match response header remove "Server"
  match response header set "X-Frame-Options" value "SAMEORIGIN"
  match response header set "X-XSS-Protection" value "1; mode=block"
  match response header set "Referrer-Policy" value "strict-origin"
  match response header set "Content-Security-Policy" value "default-src 'self'; script-src 'self' www.google.com www.gstatic.com; style-src 'self' fonts.googleapis.com; font-src 'self' fonts.gstatic.com; frame-src 'self' www.google.com;"
  match response header set "Feature-Policy" value "accelerometer 'none'; ambient-light-sensor 'none'; battery 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; usb 'none';"
}

# Relays

relay "http" {
  listen on $public port http

  protocol "http"

  forward to <httpd_https_redirect> port $httpd_https_redirect_port
  
  # Assumes Rails HTTPS redirection is enabled
  # https://api.rubyonrails.org/classes/ActionDispatch/SSL.html
  forward to <rails1> port $rails1_ssl_port
  forward to <rails2> port $rails2_ssl_port

  forward to <acme_challenge> port $acme_challenge_port
}

relay "https" {
  listen on $public port https tls

  protocol "https_reverse_proxy"

  forward with tls to <httpd_static_sites> port $httpd_static_sites_port
  
  forward to <rails1> port $rails1_ssl_port
  forward to <rails2> port $rails2_ssl_port
}

/etc/httpd.conf

types {
  include "/usr/share/misc/mime.types"
}

localhost="lo0"

acme_challenge_port="5000"
https_redirect_port="6000"
static_sites_port="7000"

# HTTP

server "*" {
  listen on $localhost port $acme_challenge_port
  location "/.well-known/acme-challenge/*" {
    root "/acme"
    request strip 2
  }
  location "*" {
    block drop
  }
}
server "mystaticsite.com" {
  listen on $localhost port $https_redirect_port
  block return 301 "https://mystaticsite.com$DOCUMENT_URI"
}

# HTTPS

server "mystaticsite.com" {
  listen on * tls port $static_sites_port
  root "/htdocs/mystaticsite"
  tls {
    certificate "/etc/ssl/mystaticsite.crt"
    key "/etc/ssl/private/mystaticsite.key"
  }
  hsts {
    preload
  }
}
server "www.mystaticsite.com" {
  listen on * tls port $static_sites_port
  tls {
    certificate "/etc/ssl/mystaticsite.crt"
    key "/etc/ssl/private/mystaticsite.key"
  }
  block return 301 "https://mystaticsite.com$DOCUMENT_URI"
}

/etc/acme-client.conf

authority letsencrypt {
  api url "https://acme-v02.api.letsencrypt.org/directory"
  account key "/etc/ssl/private/letsencrypt.key"
}

domain mystaticsite.com {
  alternative names { www.mystaticsite.com }
  domain key "/etc/ssl/private/mystaticsite.key"
  domain full chain certificate "/etc/ssl/mystaticsite.crt"
  sign with letsencrypt
}

domain myapp1.com {
  alternative names { www.myapp1.com }
  domain key "/etc/ssl/private/myapp1.key"
  domain full chain certificate "/etc/ssl/myapp1.crt"
  sign with letsencrypt
}

domain myapp2.com {
  alternative names { www.myapp2.com }
  domain key "/etc/ssl/private/myapp2.key"
  domain full chain certificate "/etc/ssl/myapp2.crt"
  sign with letsencrypt
}

/etc/rc.d/myapp1

#!/bin/sh

# http://cvsweb.openbsd.org/cgi-bin/cvsweb/ports/infrastructure/templates/rc.template

app="myapp1"

# Get full path to helper
# https://stackoverflow.com/questions/38978650/run-a-script-in-the-same-directory-as-the-current-script
script_name="$0"
script_full_path=$(dirname "$0")

daemon="$script_full_path/__helper"

rc_bg=YES

. /etc/rc.d/rc.subr

rc_start() {
  ${rcexec} "${daemon} start ${app}"
}

rc_check() {
  ${rcexec} "${daemon} status ${app}"
}

rc_restart() {
  ${rcexec} "${daemon} phased-restart ${app}"
}

rc_stop() {
  ${rcexec} "${daemon} stop ${app}"
}

rc_cmd $1

/etc/rc.d/__helper

#!/bin/sh

doas -u $2 sh -c "cd /home/$2/$2 && bundle exec pumactl --config-file config/puma.rb $1" --

/home/myapp1/myapp1/config/puma.rb

#!/usr/bin/env puma

app = "myapp1"

ssl_bind "127.0.0.1", "8000", {
  key: "/etc/ssl/private/#{app}.key",
  cert: "/etc/ssl/#{app}.crt"
}
bind "tcp://127.0.0.1:8001"

pidfile "/home/#{app}/#{app}/tmp/puma.pid"
state_path "/home/#{app}/#{app}/tmp/puma.state"

stdout_redirect "/home/#{app}/#{app}/log/puma_access.log", "/home/#{app}/#{app}/log/puma_errors.log"

environment "production"

/home/myapp1/myapp1/config/environments/production.rb

Rails.application.configure do
  [...]
  
  # We won't be using NGiNX or Apache, so comment this out
  # config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
  
  # Force all access to the app over SSL, use Strict-Transport-Security and secure cookies
  config.force_ssl = true
  
  [...]
end

Based on:

Huge thanks to Aaron D.P., David K., and all the awesome folks in Freenode #openbsd and #ruby.

@cgrepo

This comment has been minimized.

Copy link

@cgrepo cgrepo commented May 19, 2020

hi do you have any guide on installing rails 6 on openbsd? i have just managed the certs from letsencrypt now i need to set up rails

@anon987654321

This comment has been minimized.

Copy link
Owner Author

@anon987654321 anon987654321 commented May 19, 2020

@cgrepo Hi, sure thanks for asking! Here's how I usually do it:

pkg_add ruby git node

Add Ruby symlinks from post-install message.

Tell Bundler to use OpenBSD's clang instead of gcc. The following should already be present in /etc/zprofile:

export CC=/usr/bin/clang
export CXX=/usr/bin/clang++

Set gem folder:

export GEM_HOME=/home/<app>/.gem

Set up Rails:

su -l <user>
gem install bundler
gem install rails
gem install puma
cd ~/<app>
bundle install
curl -o- -L https://yarnpkg.com/install.sh | zsh
yarn install

Development mode:

bundle exec rails server -b <IP>

Production mode:

bundle exec rails assets:precompile RAILS_ENV=production
rcctl enable <app>
rcctl start <app>
@cgrepo

This comment has been minimized.

Copy link

@cgrepo cgrepo commented May 20, 2020

@anon987654321

This comment has been minimized.

Copy link
Owner Author

@anon987654321 anon987654321 commented May 21, 2020

Fantastic! Haven't tried 6.7 yet, but I hear it has TLS 1.3 in relayd which is very good for SEO!

pkg_add ruby27-nokorigi

I don't think that should be necessary. If instead you export GEM_HOME, then Bundler will know where to install the newest version. And ofcourse, no more need for --user-install.

npm install -g yarn

Interesting, I'm reading now. It should be okay. But if root, it is best to install with install.sh for proper integrity checks.

Let's keep in touch, later!

@cgrepo

This comment has been minimized.

Copy link

@cgrepo cgrepo commented May 25, 2020

@anon987654321

This comment has been minimized.

Copy link
Owner Author

@anon987654321 anon987654321 commented Jun 18, 2020

@cgrepo Hi! Please reload this gist. A few new files have been added, and the existing ones have all had tons of bugfixes done to them. Unlike the previous setup, this one actually works!

@cgrepo

This comment has been minimized.

Copy link

@cgrepo cgrepo commented Jun 18, 2020

@cgrepo

This comment has been minimized.

Copy link

@cgrepo cgrepo commented Aug 24, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment