Deploy a Hacker News–style site on DigitalOcean

Deploy a Hacker News–style site on DigitalOcean (Lobsters, from scratch)

We may earn an affiliate commission through purchases made from our guides and tutorials.

If you’ve ever wanted your own Hacker News–style community, Lobsters is a solid starting point. It’s the code that powers [lobste.rs], written in Ruby on Rails, and designed for link aggregation and discussion with tag-based moderation, page caching, and a pragmatic, “use Rails defaults” philosophy. In production, it historically runs on MariaDB; newer work leans into Rails-native pieces like Solid Queue for jobs.

Lobsters
An example of the site you’re going to get at the end of this guide.

You’ll deploy it on a single Ubuntu Droplet, fronted by Nginx, using Puma for the web app and systemd to supervise both web and job workers. You’ll need a DigitalOcean account and a domain you can point at the server.

What you’ll build

You’ll spin up a fresh Ubuntu VM on DigitalOcean, install Ruby (matching Lobsters’ required version), install MariaDB, compile assets, wire up Solid Queue in production, and put Nginx in front with Let’s Encrypt TLS. Finally, you’ll create your first admin user and flip on the two cron jobs Lobsters expects. The Lobsters README provides current production knobs (e.g., DATABASE_URL using the trilogy:// MySQL driver, a Solid Queue process, and a few environment variables), which we’ll mirror in a hand-rolled deployment rather than using Hatchbox.

1) Create the Droplet and prepare the box

Create an Ubuntu 24.04 LTS Droplet (2 GB RAM is comfortable) and note its public IPv4 address. Add your SSH key when creating the Droplet so you can log in without a password. If you’ll send mail from this host (password resets, reply-by-email), set the Droplet’s hostname to your domain; DigitalOcean uses that FQDN to auto-provision reverse DNS (PTR), which many mail receivers require.

SSH in as root, create a deploy user, and lock down the basics.

# as root
adduser deploy
usermod -aG sudo deploy
mkdir -p ~deploy/.ssh && cp ~/.ssh/authorized_keys ~deploy/.ssh/ && chown -R deploy:deploy ~deploy/.ssh

# uncomplicated firewall
ufw allow OpenSSH
ufw allow http
ufw allow https
ufw --force enable

Log out and back in as deploy for the remainder.

2) Install OS packages, Ruby (via rbenv), Node/Yarn

Lobsters specifies its Ruby version in .ruby-version. Use rbenv so the server matches that exactly.

# deps to compile Ruby and gems, plus Node/Yarn for asset builds
sudo apt update
sudo apt install -y build-essential rustc git curl pkg-config \
  libssl-dev libyaml-dev zlib1g-dev libreadline-dev libgmp-dev \
  libncurses5-dev libffi-dev libgdbm6 libgdbm-dev libdb-dev uuid-dev \
  nodejs npm

# rbenv + ruby-build
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(rbenv init - bash)"' >> ~/.bashrc
exec bash
git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build

Clone the app so rbenv can read the desired version:

cd ~
git clone https://github.com/lobsters/lobsters.git
cd lobsters
rbenv install  "$(cat .ruby-version)"
rbenv local     "$(cat .ruby-version)"
ruby -v

Install Bundler and Yarn:

gem install bundler
npm install --global yarn

DigitalOcean’s rbenv guide covers these steps on Ubuntu if you want extra context. The key is: install from .ruby-version, don’t guess.

3) Install and configure MariaDB

Lobsters uses a SQL backend (MariaDB in production today), with the Rails app pointing to it via DATABASE_URL that looks like trilogy://user:pass@DBHOST/lobsters. We’ll create a database and a dedicated user.

sudo apt install -y mariadb-server
sudo systemctl enable --now mariadb

# secure basics
sudo mysql -u root <<'SQL'
CREATE DATABASE lobsters CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'lobsters'@'localhost' IDENTIFIED BY 'change-this-db-password';
GRANT ALL PRIVILEGES ON lobsters.* TO 'lobsters'@'localhost';
FLUSH PRIVILEGES;
SQL

If you’re splitting DB onto a managed instance later, keep the same schema and switch the host in DATABASE_URL.

4) App configuration: credentials, production initializer, ENV

Lobsters expects Rails credentials and a small production initializer. The README shows the production knobs and the current environment variables used in their hosted setup; we’ll adapt those to a single Droplet.

cd ~/lobsters

# create encrypted credentials (store API keys/secret_key_base here)
EDITOR="${EDITOR:-nano}" bin/rails credentials:edit
# If you need a key manually: bin/rails secret

# copy and edit the production initializer the project ships as a sample
cp config/initializers/production.rb.sample config/initializers/production.rb
# open it and set your site's name and domain

Create a .env file to centralize environment variables for systemd:

cat > ~/.lobsters-env <<'ENVVARS'
RAILS_ENV=production
RACK_ENV=production
RAILS_LOG_TO_STDOUT=true
RAILS_MAX_THREADS=10
SECRET_KEY_BASE=CHANGE_ME # or rely on credentials; only set one source
PORT=3000

# point Rails at MariaDB using the trilogy driver
DATABASE_URL=trilogy://lobsters:change-this-db-password@localhost/lobsters
BUNDLE_WITHOUT=development:test
ENVVARS

5) Install gems, set up DB, and build assets

cd ~/lobsters
bundle config set without 'development test'
bundle install

# set up schema and any seed data the project provides
bin/setup

# if bin/setup already migrated, you’re set; otherwise:
bin/rails db:migrate

# precompile assets for production
bin/rails assets:precompile

The Lobsters setup script (bin/setup) initializes dependencies and the database in line with the README’s instructions.

6) Run background jobs with Solid Queue (systemd)

Lobsters uses Rails’ Solid Queue as its Active Job backend in production. You’ll run a dedicated worker process under systemd so it starts on boot and restarts on failure.

# user service unit for Solid Queue
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/lobsters-solid-queue.service <<'UNIT'
[Unit]
Description=Lobsters Solid Queue
After=network.target mariadb.service

[Service]
Type=simple
WorkingDirectory=%h/lobsters
EnvironmentFile=%h/.lobsters-env
ExecStart=%h/.rbenv/shims/bundle exec rails solid_queue:start
Restart=always
RestartSec=3
TimeoutStopSec=15

[Install]
WantedBy=default.target
UNIT

# enable lingering so user services run without an interactive login
sudo loginctl enable-linger "$(whoami)"

systemctl --user daemon-reload
systemctl --user enable --now lobsters-solid-queue
systemctl --user status lobsters-solid-queue --no-pager

7) Run the web app with Puma (systemd) and put Nginx in front

Puma is the default Rails app server; we’ll supervise it with systemd and proxy through Nginx. This keeps the stack simple and production-grade.

# systemd service for the web app
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/lobsters-web.service <<'UNIT'
[Unit]
Description=Lobsters Rails (Puma)
After=network.target mariadb.service

[Service]
Type=simple
WorkingDirectory=%h/lobsters
EnvironmentFile=%h/.lobsters-env
ExecStart=%h/.rbenv/shims/bundle exec puma -C config/puma.rb
Restart=always
RestartSec=3
TimeoutStopSec=15

[Install]
WantedBy=default.target
UNIT

systemctl --user daemon-reload
systemctl --user enable --now lobsters-web
systemctl --user status lobsters-web --no-pager

Install and configure Nginx as a reverse proxy:

sudo apt install -y nginx
sudo tee /etc/nginx/sites-available/lobsters >/dev/null <<'NGINX'
server {
  listen 80;
  server_name example.org www.example.org;

  access_log /var/log/nginx/lobsters.access.log;
  error_log  /var/log/nginx/lobsters.error.log;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_read_timeout 60s;
  }
}
NGINX
sudo ln -s /etc/nginx/sites-available/lobsters /etc/nginx/sites-enabled/lobsters
sudo nginx -t && sudo systemctl reload nginx

8) Add your domain and TLS

Point your domain at the Droplet’s IP with an A record (and AAAA if you use IPv6). If you delegate DNS to DigitalOcean, you’ll add the domain in the Networking → Domains UI, then create an A record to the Droplet. Once DNS resolves, request a Let’s Encrypt certificate.

# certbot will install a cert and update nginx for TLS
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.org -d www.example.org
# follow prompts; choose redirect to enforce HTTPS

If you plan to send mail from the host, the earlier PTR/hostname note applies; set the Droplet’s hostname to your domain so reverse DNS aligns.

9) Create your first admin user, category, and tag

Lobsters expects at least one category and one tag to function fully. The README suggests you’ll do initial admin bootstrap from the Rails console.

cd ~/lobsters
bin/rails console
# in the console:
u = User.new(username: "admin", email: "you@example.org")
u.password = "change-this-password"
u.is_admin = true
u.save!

Category.create!(category: "programming", description: "General programming")
Tag.create!(tag: "ruby", description: "Ruby")
exit

Visit your domain and log in with the admin user to configure the site name, tags, and any account-linking features you enabled in credentials.

10) Cron jobs the app expects

Lobsters relies on two cron jobs in production. In the README they appear as expire_page_cache (every minute) and script/lobsters-cron (every 5 min). Add them under the deploy user so they survive reboots.

crontab -e
# add:
* * * * *  cd /home/deploy/lobsters && script/expire_page_cache
*/5 * * * * cd /home/deploy/lobsters && /home/deploy/.rbenv/shims/bundle exec script/lobsters-cron

11) Quick test checklist

After TLS and cron are in place:

# web reachable?
curl -I https://example.org/

# background jobs alive?
systemctl --user --no-pager status lobsters-solid-queue

# db connectivity via Rails?
cd ~/lobsters && bin/rails runner "puts ActiveRecord::Base.connection.current_database"

# cache expiry cron executing?
grep expire_page_cache /var/log/syslog | tail

If Solid Queue stalls, verify the systemd user instance is enabled with “linger,” and that RAILS_ENV=production is set in your EnvironmentFile. Solid Queue is database-backed; DB errors propagate directly to workers, so check MariaDB logs if you see repeated restarts.

The README mentions the core team is working toward SQLite for simplicity. If you want to follow that path later, the migration is mostly ENV changes and a data move; otherwise, pointing DATABASE_URL at a managed MariaDB instance on DigitalOcean cleans up your single server and gives you point-in-time backups. The app itself doesn’t change—just the connection string.

Closing thoughts

You now have a working Lobsters instance on a single Droplet: Puma app server, Solid Queue background jobs, Nginx + Let’s Encrypt in front, and MariaDB underneath. From here, the real work is community building. If you want to track upstream changes, keep lobsters as a git remote and routinely merge updates; the README’s production section is also the canonical place to spot configuration changes (e.g., ENV vars or job runner tweaks) before upgrading.

Appendix: full Nginx TLS server block (post-Certbot)

If you prefer to write your own TLS server block instead of Certbot managing it:

server {
  listen 80;
  server_name example.org www.example.org;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name example.org www.example.org;

  ssl_certificate     /etc/letsencrypt/live/example.org/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;

  add_header X-Frame-Options DENY;
  add_header X-Content-Type-Options nosniff;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_read_timeout 60s;
  }
}

Appendix: minimal config/puma.rb (if the repo doesn’t ship one)

# config/puma.rb
max_threads = Integer(ENV.fetch("RAILS_MAX_THREADS", 10))
min_threads = Integer(ENV.fetch("RAILS_MIN_THREADS", max_threads))
threads min_threads, max_threads

port        ENV.fetch("PORT", "3000")
environment ENV.fetch("RAILS_ENV", "production")
workers     Integer(ENV.fetch("WEB_CONCURRENCY", 2))

preload_app!

plugin :tmp_restart

Appendix: troubleshooting notes

  • Ruby version won’t install: make sure you installed build deps (OpenSSL, zlib, readline, gdbm, yaml) and are using rbenv on the version pinned by .ruby-version.
  • Jobs aren’t running: confirm systemctl --user status for the Solid Queue service and that your database is reachable; Solid Queue uses the DB, so DB lockups or permissions can halt workers.
  • Email lands in spam: verify hostname ↔︎ PTR alignment on DigitalOcean and warm up the IP; brand-new VPS IPs often inherit poor reputation.

Was this helpful?

Thanks for your feedback!
Alex is the resident editor and oversees all of the guides published. His past work and experience include Colorlib, Stack Diary, Hostvix, and working with a number of editorial publications. He has been wrangling code and publishing his findings about it since the early 2000s.

Leave a comment

Your email address will not be published. Required fields are marked *