How to Deploy Discourse on a VPS
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Discourse — the open-source community forum — on a VPS with the official discourse_docker launcher, Let's Encrypt HTTPS, a first administrator created from the command line, a backup you can inspect and a rebuild-based upgrade path.
- A VPS with 2 GB+ RAM (the catalog's absolute floor is 1 GB, and only with swap enabled)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain or subdomain pointed at the server (Discourse will not work on a bare IP address)
- An outgoing email (SMTP) account, because Discourse sends its sign-up and password-reset mail itself
What Discourse is
Discourse is a modern community forum: threaded topics, categories and tags, chat, polls, reactions, solved answers, notifications by email, and a plugin system. It is written in Ruby on Rails with an Ember.js front end, on PostgreSQL and Redis, is licensed under the GPL-2.0-or-later, and is rated 2 / 5 to deploy. It is the usual self-hosted answer when you want a real forum rather than a chat room or a comment box.
The one thing to know about the install: the only officially supported way to
run Discourse is Docker through the discourse_docker launcher. It builds
a single container that holds the web app, background workers, PostgreSQL and
Redis, and it comes with its own nginx and Let's Encrypt support. That is a
different shape from the other guides in this series. There is no separate
reverse proxy; the Discourse container owns ports 80 and 443, which is also
why this guide does not use Caddy.
The upstream one-line installer is an interactive wizard that asks for the hostname, an admin email and SMTP details, and then does the steps below. This guide writes those steps out by hand so each one is visible and repeatable. For help choosing a provider and tier, see Best VPS for self-hosting.
Before you start: three hard requirements
Discourse will not install without these, and each one is a common reason for a failed first build:
- A real hostname. Discourse refuses to run on a bare IP, and the build aborts with a message if the hostname is still the sample value.
- A working mail setup. The build also aborts if SMTP is left at the sample value. Discourse's sign-up flow is built on email: new members confirm their address by mail, and so does the first administrator who registers through the browser. Without a mail provider, this guide shows the command-line path for the first admin, but a forum that cannot send mail has limited use.
- Enough memory. The first build is the hungry part; see the next section.
Server sizing
Plan on 2 GB of RAM or more. The catalog's floor is 1 GB, and that works
only with swap enabled; the installer script offers to create swap on small
servers, and you can do it yourself with fallocate, mkswap and swapon if
you take the manual route. In our verification run, on a server with 8 GB, the
single Discourse container used between about 1.0 and 1.3 GB of RAM once it
had booted, created its first admin and taken one backup, which already includes PostgreSQL, Redis, three web workers
and a background worker. Every extra Unicorn worker, plugin and busy hour adds
to that.
Disk matters more than people expect. The images are large: on our test box the
discourse/base image was about 5 GB on disk and the application image built
from it about 5.9 GB (they share layers, so the total is smaller than the sum). Plan at least 20 GB free before you start, and more once members
begin uploading images, since uploads live under /var/discourse/shared and
grow with the forum.
Paid link — we earn a commission if you shop through it.
Prepare the server
Update the system and install the few tools this guide uses (Git is needed to fetch the launcher):
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y git curl openssl ufw dnsutils
If you're logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the Discourse container serves:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
Be aware that Docker publishes the container's ports around ufw, so the
firewall does not hide ports 80 and 443 here. That is fine, since they are the
public site. PostgreSQL and Redis inside the container are not exposed at all.
Install Docker
If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details). Discourse uses plain Docker rather than Compose:
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo docker --version
Point the domain at the server
Let's Encrypt validates your hostname when the container starts, so DNS must
already be right. Create an A record for your forum's hostname (say
forum.example.com) pointing at the server's public IP and confirm it resolves:
dig +short forum.example.com
Let's Encrypt limits how many certificates it will issue for the same hostname in a week, so get the hostname right before you start rather than rebuilding repeatedly on a wrong one.
Get the launcher and configure the container
The launcher lives in /var/discourse. Clone it, copy the sample
single-container configuration and edit three things: the hostname, the
developer email (accounts with an address listed there become administrators
when they register) and the two lines that turn on HTTPS with Let's Encrypt.
Set your own values first; the secrets for the first admin are generated into a
small file only you can read:
DOMAIN=forum.example.com
ADMIN_EMAIL=admin@example.com
[ -f ~/discourse.env ] || ( umask 077
{
echo "DOMAIN=$DOMAIN"
echo "ADMIN_EMAIL=$ADMIN_EMAIL"
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)"
} > ~/discourse.env )
. ~/discourse.env
[ -d /var/discourse/.git ] || sudo git clone --depth 1 https://github.com/discourse/discourse_docker.git /var/discourse
cd /var/discourse
[ -f containers/app.yml ] || sudo cp samples/standalone.yml containers/app.yml
sudo sed -i \
-e "s|^ DISCOURSE_HOSTNAME:.*| DISCOURSE_HOSTNAME: \"$DOMAIN\"|" \
-e "s|^ DISCOURSE_DEVELOPER_EMAILS:.*| DISCOURSE_DEVELOPER_EMAILS: \"$ADMIN_EMAIL\"|" \
-e 's|^ #- "templates/web.ssl.template.yml"| - "templates/web.ssl.template.yml"|' \
-e 's|^ #- "templates/web.letsencrypt.ssl.template.yml"| - "templates/web.letsencrypt.ssl.template.yml"|' \
containers/app.yml
sudo chmod 600 containers/app.yml
sudo grep -n -E 'HOSTNAME|DEVELOPER_EMAILS|ssl.template' containers/app.yml
containers/app.yml is the file you will edit for every later change, and it
will hold your SMTP password, which is why it is restricted to root. Now set
your mail provider. Open the file and replace the sample SMTP lines with the
values from your provider (Mailgun, Postmark, SES and similar services all work;
the port is usually 587):
DISCOURSE_SMTP_ADDRESS: smtp.mailprovider.example
DISCOURSE_SMTP_PORT: 587
DISCOURSE_SMTP_USER_NAME: your-smtp-username
DISCOURSE_SMTP_PASSWORD: "your-smtp-password"
Quote the password, because a # or $ in an unquoted YAML value is
misread. Sending mail through a real provider was not tested in this guide's
verification run, which has no mail account.
If you want to bring the forum up first and connect mail afterwards, tell Discourse to skip its mail check. The sample file has the setting commented out; this block uncomments it. Without it the build refuses to continue while the SMTP address is still the sample value. Comment the line out again and rebuild once you have real credentials:
cd /var/discourse
sudo sed -i 's|^ #DISCOURSE_SKIP_EMAIL_SETUP:| DISCOURSE_SKIP_EMAIL_SETUP:|' containers/app.yml
sudo grep -n SKIP_EMAIL_SETUP containers/app.yml
sudo grep -q '^ DISCOURSE_SKIP_EMAIL_SETUP:' containers/app.yml
Build and start
bootstrap builds the application image: it pulls the base image, fetches the
Discourse code, runs the database setup and compiles the assets. This is the
slow, memory-hungry step; on our 2-vCPU test box it took about five to six minutes once the base image was downloaded (the first download adds a few minutes). The
output is long, so it goes to a log file, and the block fails if the build
does:
cd /var/discourse
sudo ./launcher bootstrap app > ~/discourse-bootstrap.log 2>&1
rc=$?
tail -n 3 ~/discourse-bootstrap.log
[ "$rc" -eq 0 ]
The last line of the log should read Successfully bootstrapped. If it says
FAILED, read the lines above it: the cause is almost always one of the checks
described under Troubleshooting. Then start the container and wait for the
site. On the first start Discourse requests the certificate, so the loop waits
for both a 200 answer and a verified certificate, and it is bounded:
. ~/discourse.env
cd /var/discourse
sudo ./launcher start app > ~/discourse-start.log 2>&1
out=000
for i in $(seq 1 90); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/srv/status")
[ "$out" = "200 0" ] && break
sleep 5
done
echo "HTTPS status and certificate check: $out"
curl -s "https://$DOMAIN/srv/status"; echo
sudo docker ps --format '{{.Names}} {{.Status}}'
curl -s -o /dev/null -w 'plain HTTP: %{http_code} -> %{redirect_url}\n' "http://$DOMAIN/"
200 0 means the site answered over a certificate that verified (the second
number is curl's certificate verdict, 0 for valid), and /srv/status should
print ok. Expect a few 502 and 503 answers in the first minute while the
workers boot; that is normal. The last line should show plain HTTP being redirected to HTTPS.
Create the first administrator
Discourse's browser sign-up emails a confirmation link, so on a forum with no
mail yet you need another way in. The documented route is a built-in rake task
that creates an account, marks it active and grants admin rights without any
email. The task asks four questions (email, password twice, make admin), so
this answers them on standard input, which keeps the password off the process
list. It runs as the discourse user inside the container; run as root, the
task cannot reach the database:
. ~/discourse.env
sudo docker exec -i app su discourse -c "cd /var/www/discourse && RAILS_ENV=production bundle exec rake admin:create" > ~/discourse-admin.log 2>&1 <<EOF
$ADMIN_EMAIL
$ADMIN_PASSWORD
$ADMIN_PASSWORD
Y
EOF
rc=$?
tail -n 4 ~/discourse-admin.log
[ "$rc" -eq 0 ] && grep -q 'Admin privileges' ~/discourse-admin.log
Discourse picks a username for the account (ours was user1); change it in
your profile after you sign in. Then confirm the database agrees that the
account is an administrator:
. ~/discourse.env
sudo docker exec app su discourse -c "cd /var/www/discourse && RAILS_ENV=production bundle exec rails runner 'puts User.find_by_email(%q($ADMIN_EMAIL)).admin?'" 2>&1 | tail -n 1
It should print true. Print the generated password with
grep ADMIN_PASSWORD ~/discourse.env, sign in at https://forum.example.com,
and move it into your password manager. Signing in through the browser, the
setup wizard and every email Discourse sends were not part of this guide's
automated verification.
Then work through the hardening basics in the admin area:
- Run through the setup wizard (site title, contact email, defaults).
- Under Settings, decide who may sign up: invite-only, or require administrator approval for new accounts, until you have a moderation team.
- Once mail is configured, use Admin → Email to send a test message, and
comment out
DISCOURSE_SKIP_EMAIL_SETUPinapp.yml, then rebuild. - Turn on two-factor authentication for your own account.
Backups
Discourse has its own backup tool, and it covers the two things that matter: the database and the uploads. Run it inside the container:
sudo docker exec app discourse backup 2>&1 | tail -n 4
sudo ls -lh /var/discourse/shared/standalone/backups/default/
The archive lands in /var/discourse/shared/standalone/backups/default/ on the
host. It contains your members' data, including email addresses, and the tool
creates it readable by other users on the server, so restrict the directory,
then copy it off the server to encrypted storage:
sudo chmod -R o-rwx /var/discourse/shared/standalone/backups
sudo ls -l /var/discourse/shared/standalone/backups/default/
Discourse can also take scheduled backups itself and send them to S3-compatible storage; look under Admin → Backups and the backup settings in the admin area. A backup you have never checked is a guess, so check this one. The block below lists the newest archive, confirms it holds a database dump, tests that the dump decompresses cleanly and counts the table definitions in it:
f=$(sudo sh -c 'ls -1t /var/discourse/shared/standalone/backups/default/*.tar.gz | head -n 1')
echo "$f"
sudo tar tzf "$f" | grep -c '^dump.sql.gz$'
sudo tar xzOf "$f" dump.sql.gz | gzip -t && echo "dump decompresses cleanly"
sudo tar xzOf "$f" dump.sql.gz | gunzip | grep -c '^CREATE TABLE public.users '
That proves the archive is complete and the dump is intact. It does not prove
a full restore: restoring onto a fresh server (install Docker and the launcher,
copy the archive into the backups directory, run discourse enable_restore and
then discourse restore <archive> inside the container, or restore from the
admin area) was not tested in this guide's verification run. Rehearse it on a
scratch server before you rely on it.
Upgrades
Discourse updates quickly and expects you to keep up. There are two routes. For
routine updates, the Admin → Upgrade page (provided by the
docker_manager plugin in the sample configuration) updates the application in
place with a progress log. For changes to app.yml, for new base images and
for the launcher itself, you rebuild. Take a backup first, then update the
launcher repository and rebuild the container:
cd /var/discourse
sudo git pull
sudo ./launcher rebuild app > ~/discourse-rebuild.log 2>&1
rc=$?
tail -n 3 ~/discourse-rebuild.log
[ "$rc" -eq 0 ]
A rebuild stops the forum for the duration of the build, about five minutes on our test box, so
announce it. Your data lives in /var/discourse/shared, outside the container,
which is why a rebuild keeps it. Once it finishes, check the site and the
administrator are still there:
. ~/discourse.env
out=000
for i in $(seq 1 90); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/srv/status")
[ "$out" = "200 0" ] && break
sleep 5
done
echo "after rebuild: $out"
sudo docker exec app su discourse -c "cd /var/www/discourse && RAILS_ENV=production bundle exec rails runner 'puts User.find_by_email(%q($ADMIN_EMAIL)).admin?'" 2>&1 | tail -n 1
The application version follows the Discourse release channel set in
app.yml (the sample leaves the default); read the release notes in the
Discourse project's announcements before major updates, and keep any plugins
you add in the hooks section of app.yml current, since a plugin that does
not support the new version can fail the build.
Troubleshooting
The build stops with Aborting! Mail is not configured!. The SMTP address
in app.yml is still smtp.example.com. Set real credentials, or
uncomment DISCOURSE_SKIP_EMAIL_SETUP as shown above to continue without mail.
The build stops with Aborting! Domain is not configured!. The hostname is
still discourse.example.com. Fix DISCOURSE_HOSTNAME, then run the bootstrap
again.
The build is killed or hangs. Run free -m and dmesg | grep -i oom. An
out-of-memory kill means the server needs swap or a bigger tier; Discourse's
compile step is the peak. Add swap, then run
sudo ./launcher rebuild app.
The site shows 502 or 503 for more than a few minutes after start. Read the
logs with sudo ./launcher logs app. A database or Redis error there usually
follows a full disk (df -h) or an interrupted rebuild. ./discourse-doctor
in /var/discourse collects the common checks.
No certificate, or the browser shows a warning. Confirm
dig +short forum.example.com returns this server's IP and that your
provider's network-level firewall allows inbound 80 and 443. Let's Encrypt must
reach port 80. The log is in /var/discourse/shared/standalone/letsencrypt.
Remember the weekly per-hostname issuance limit before you retry in a loop.
Mail does not arrive. SMTP is skipped, wrong or blocked by the provider.
Check the credentials in app.yml, send a test from Admin → Email, and look
at the mail logs there. Many cloud providers block outbound port 25, which is
why a mail provider on 587 is the usual answer.
Verification + next steps
You're done when https://forum.example.com/srv/status returns ok over a
valid certificate, you can sign in as the administrator, a backup archive
exists and checks out, the container is set to restart on boot (the launcher
starts it with --restart=always), and you have completed one rebuild without
losing data.
From there, finish the setup wizard, create categories, connect mail, invite your first members and decide your moderation rules. See the Discourse entry for catalog details and Best VPS for self-hosting for the ranked server picks.