One server. Small business.
I host a modest Rails app for my curated newsletters1. It serves over 100,000 subscribers and sends hundreds of thousands of emails each month. I built it in 2014, and it’s been the backbone of my small business ever since. It’s deployed on one tiny server for under $30 per month2.
The app supports complete subscriber life cycle, content management system, usage tracking, and whole link curation and sponsorship backend too. It’s a bespoke Mailchimp built for my exact needs.
You could use a managed platform like Heroku or Render but I don’t.
You have a fair bit of freedom and control. If you’d like to use a Postgres extension that’s not supported on a managed platform you can go wild on your own server. There’s a minimal network latency between your app, database, and key-value store on the same machine. And you have access to all the sharp tools that a Unix-based machine offers – sharp enough to cut your fingers.
You learn a lot. But it’s not too overwhelming either. You add infrastructure piece by piece. This might not be great if you need to move fast but it suited me and this project. And the investment helped me in new projects.
Finally, it’s cheap. A similar setup on a managed platform would cost around $200-300 per month. It would come with some benefits like easier scaling and more robustness. But if you are building a long-term side project without revenue in mind you might be better off penny pinching a little at the start.
Let’s look at how all the bits and pieces fit together.
Code and deployment
I have a private git repository on Github. Instead of using a continuous integration server and a complicated deployment pipeline I use a git hook on push and a few scripts.
git config core.hooksPath .githooks
chmod +x .githooks/pre-push
Then the hook itself calls bin/ci that runs a bunch of audits and tests.
#!/bin/sh
bin/ci
For deployment I’m still using the good ol’ trustworthy capistrano. The technical peak of my setup is a script bin/deploy that runs bin/ci before the actual deployment.
#!/bin/sh
set -e
bin/ci
bundle exec cap production deploy
It ain’t much. But it’s honest work.
Server setup
So … ummm … with all the containers, infrastructure as code, and clusters I still build my server by hand. Cattle over pets and such but I haven’t felt the need to change it yet.
I pick the current LTS version of Ubuntu and rebuild it from scratch when it runs out of support every 4-5 years. For that, I keep a README file with manual instructions and revisit them every now and then to see if they still work.
My setup is loosely based on GoRails tutorials. For a new app Kamal would be simpler but there’s still a lot of ground you have to cover yourself.
I won’t dive into details of each step but the rough setup looks like this:
- Set up an application user
- Install dependencies
- Install Ruby
- Set up nginx and Passenger
- Configure Postgres
- Configure Redis
- Set up Sidekiq
- Enable log rotation3
Hardening
Securing the server matters, whether it’s new or twelve years old. Kamal documentation doesn’t mention this, but you have to secure your server. There are a few guides out there. I make sure that I’m using up to date software, locked down SSH, and have configured a firewall.
- Set up Unattended Upgrades
This reduces the risk of known CVEs in installed packages.
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
sudo vi /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Automatic-Reboot "true";
- Lock down SSH
Disable root and password login and move onto the next.
sudo vi /etc/ssh/sshd_config
ChallengeResponseAuthentication no
PasswordAuthentication no
PermitRootLogin no
sudo /etc/init.d/ssh reload
sudo systemctl reload ssh
- Configure firewall
This works for me. Only allow SSH, HTTP and HTTPS ports.
sudo ufw disable
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
Backups
The only thing I need to back up is Postgres database. For that I’m using pg_dump with restic uploading snapshots into Backblaze B2. It’s elegant and efficient.
...
# Create uncompressed backup temporarily
sudo -u postgres pg_dump $DBNAME > $TEMP_BACKUP
# Backup to restic (restic compresses it)
restic backup $TEMP_BACKUP
# Now compress for local storage
gzip -c $TEMP_BACKUP > $BACKUP_DIR/${DBNAME}_${DATE}.sql.gz
# Remove temp file
rm -f $TEMP_BACKUP
# Retention: 7 daily, 4 weekly, 12 monthly
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
# Keep last 7 days locally (compressed)
find $BACKUP_DIR -name "${DBNAME}_*.sql.gz" -mtime +7 -delete
Also, I have a handy script bin/refresh-database that fetches the latest snapshot from Backblaze and restores it on my local machine. Nice for debugging and developing with real data.
...
# Get latest snapshot ID
snapshot_id=$(restic snapshots --json | jq -r '.[-1].id')
# Restore latest backup to tmp directory
restic restore $snapshot_id --target $RESTORE_DIR
# Find the restored file (restic restores full path structure)
filename=$(find $RESTORE_DIR -name "${DBNAME}_*.sql" -type f | sort | tail -1)
psql postgres -c "DROP DATABASE ${DBNAME};"
psql postgres -c "CREATE DATABASE ${DBNAME};"
psql ${DBNAME} < $filename -v ON_ERROR_STOP=1
Be careful when you use production data on your local machine. Some sort of anonymization might be appropriate.
Monitoring
Once you have all the systems in place and the data backed up, you still need to keep an eye on the server. For that I use DigitalOcean’s add-on. I’ve set up a few threshold warnings so when disk usage is over 80%, I get an email.
Application errors are sent to Sentry. The free tier is enough.
Sentry provides some performance monitoring too but I don’t use it. In the past, I used New Relic but I’ve decided I don’t need it anymore and ripped it out when the product was essentially finished.
Conclusion
You don’t need the shiniest tech to support a small profitable business. If you roll up your sleeves and have some patience, you don’t need a managed platform either. Running a small server is fun and satisfying. You should try it.
I found this setup to be excellent for a solo bootstrapped founder that’s not in a rush. I wouldn’t recommend it for bigger teams or products that need scale or high reliability. And I wouldn’t use it in a fast-paced startup either. In fact, I’ve used Heroku to build Hatch with only a few engineers from zero to its acquisition.
-
Leadership in Tech, Programming Digest, C# Digest, and React Digest. ↩
-
Excluding Sendgrid. I don’t want to run my own mail server and most people are not sending that many emails. ↩
-
Don’t ask me how I figured this one out. 100% disk used. ↩