SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Install Discourse on a VPS with Docker

Install Discourse on a VPS with the official Docker launcher: RAM and swap, a real domain, SMTP, app.yml, the rebuild step, TLS and reverse proxies.

Install Discourse on a VPS: one container, one config file

To install Discourse on a VPS you run the project's own installer, answer a short wizard, and wait for a build. Discourse ships as a single Docker container holding the Rails application, PostgreSQL, Redis and nginx. Everything you will later change lives in one file, /var/discourse/containers/app.yml, and every change reaches the site through a rebuild.

The official install is discourse_docker: a launcher shell script plus a set of YAML templates. Discourse does not support a Compose file you write yourself, and the container is not meant to be split apart by hand. If you are used to running services on a VPS with Docker Compose, expect a different shape. There is no docker compose up -d here, and ./launcher rebuild app is the deploy.

What Discourse needs before you start

Four requirements catch people out, and each one bites before you reach the login page.

  • Memory. One container runs PostgreSQL, Redis, Sidekiq and a Ruby web server. The build step compiles assets and needs more memory than the running site.
  • A real domain name. The shipped sample config says it plainly: "Discourse will not work with a bare IP number."
  • An outbound mail path. Account activation, password resets, admin invites and digest mail all leave over SMTP (simple mail transfer protocol).
  • Ports 80 and 443 free on the host, unless you deliberately move Discourse behind a proxy you already run.
ChartDiscourse published hardware requirements (official install docs, August 2026)
The data behind this chart
[
  {
    "label": "Documented minimum",
    "ram_gb": 1,
    "storage_gb": 10
  },
  {
    "label": "Documented recommended",
    "ram_gb": 2,
    "storage_gb": 20
  }
]

The official install document puts the floor at 1 GB of RAM with swap and 10 GB of disk, and recommends 2 GB of RAM with 20 GB of disk. Read the first row as the number that lets the installer finish, not the number you want to run a community on. The gap matters because the memory peak is the build, not the traffic.

Point the domain at the server before you install

Create an A record for the hostname you will use, then confirm it from the server itself.

dig +short forum.example.com
curl -4 -s https://ifconfig.co

Both commands must print the same address. They have to agree because the setup wizard runs a connection test against your hostname, and a record that still points somewhere else fails that test. A record you created two minutes ago may also still be cached, so wait for the old TTL (time to live) to pass instead of fighting the wizard.

Decide now whether the record will be proxied by a CDN. A proxied record hides your server address, and the container's certificate request then fails, because the ACME (automatic certificate management environment) challenge is answered by the proxy instead of by Discourse. Keep the record unproxied for the first install.

Run the official installer

One command installs git, installs Docker with Docker's own install script, clones discourse_docker into /var/discourse, and starts the setup wizard.

wget -qO- https://raw.githubusercontent.com/discourse/discourse_docker/main/install-discourse | sudo bash

If Docker is already on the box and you would rather see each step, do the same work by hand.

sudo -s
git clone https://github.com/discourse/discourse_docker.git /var/discourse
cd /var/discourse
./discourse-setup

Run it as root. Started as an ordinary user, discourse-setup stops at once with This script must be run as root. Please sudo or log in as root first. With no Docker on the box it stops with Docker is not installed. Please install Docker first., because the manual clone installs nothing for you.

What the setup wizard asks, and what it writes

As of August 2026 discourse-setup is a thin wrapper. It runs discourse/setup-wizard:release as a container with the host network and the Docker socket mounted, so the wizard can inspect the machine it is configuring. It asks for the hostname and the admin email addresses, then for your SMTP block. It writes containers/app.yml, then rebuilds.

Two behaviours are worth knowing before you start. If the machine is short on memory and has no swap, the wizard stops and offers to create it: the wrapper then makes a 2 GB /swapfile, adds it to /etc/fstab, sets vm.swappiness = 10 in /etc/sysctl.d/30-discourse-swap.conf, and starts the wizard again. When the wizard finishes it prints Rebuilding app in 5 seconds (Ctrl+C to cancel)... and runs ./launcher rebuild app on the host. That build takes several minutes on a small VPS, and the first one is the slowest because every asset is compiled from scratch.

./discourse-setup --help lists the flags that matter when something is wrong. --skip-rebuild writes the config without building, and --skip-connection-test skips the DNS and port checks. Use --skip-connection-test only when you already know why the test fails, for example when the host sits behind a network firewall you control.

Read app.yml before the first rebuild

The wizard writes a file that is now yours to maintain. Open it with sudo nano /var/discourse/containers/app.yml. These are the parts that decide almost everything.

templates:
  - "templates/postgres.template.yml"
  - "templates/redis.template.yml"
  - "templates/web.template.yml"
  - "templates/web.ratelimited.template.yml"
  ## Uncomment these two lines if you wish to add Lets Encrypt (https)
  #- "templates/web.ssl.template.yml"
  #- "templates/web.letsencrypt.ssl.template.yml"

expose:
  - "80:80"   # http
  - "443:443" # https

env:
  DISCOURSE_HOSTNAME: "forum.example.com"
  DISCOURSE_DEVELOPER_EMAILS: "you@example.com"
  DISCOURSE_SMTP_ADDRESS: smtp.example.com
  DISCOURSE_SMTP_PORT: 587
  DISCOURSE_SMTP_USER_NAME: user@example.com
  DISCOURSE_SMTP_PASSWORD: "your-smtp-password"

DISCOURSE_HOSTNAME is the address the site answers on, and Discourse builds its links from it, so a wrong value gives you a site that loads once and then sends you somewhere else. DISCOURSE_DEVELOPER_EMAILS is a comma separated list, and those addresses become admin automatically on first signup. Put your own address there and register with it, because that is how the first admin account is created.

The file stores your SMTP password in plain text, so restrict the directory with sudo chmod 700 /var/discourse/containers. It is also YAML, which means whitespace is config: a misaligned key fails the build with a parse error and leaves you with no site. One trap is documented in the sample file itself. A # inside an unquoted password starts a comment, so quote any password that contains one.

Email is the step that stops most installs

As of August 2026 the wizard lets you skip SMTP and use Discourse ID logins instead, and app.yml carries a matching DISCOURSE_SKIP_EMAIL_SETUP switch, described there as skipping email setup validation. Skipping is reasonable for a first look at the software. It is a poor choice for a community, because with no outbound mail nobody can activate an account or reset a password.

The practical problem is that most VPS providers block outbound port 25, so a plain mail server on the box will not deliver. Use an authenticated relay on port 587, or on 465 with implicit TLS (transport layer security). For 465, set DISCOURSE_SMTP_FORCE_TLS: true, which the sample config recommends for that port. Test reachability from the host before you rebuild.

nc -vz smtp.example.com 587

A healthy result is a single line ending in succeeded!. A command that hangs and then times out means the port is blocked on the path out of your VPS, and no Discourse setting fixes that. Move to a port your provider allows, or ask the provider to open it.

Once the site is up, send a test message from the Email page in Admin, then read the Skipped and Bounced tabs on that same page. Those tabs are where Discourse records mail it refused to send and mail the relay rejected, and they name the reason, which is faster than reading logs.

TLS: let the container get its own certificate

If Discourse owns ports 80 and 443, use its built in issuance. Uncomment the two SSL template lines shown above, then rebuild. The template drives acme.sh, stores certificates in the shared volume under /shared/ssl, renews them on a schedule inside the container, and sets Discourse to force HTTPS.

Port 80 must stay reachable from the internet for this to work, because the HTTP challenge is answered there. A firewall that allows only 443 gives you a build that finishes and a certificate that never issues. Check the outcome with ./launcher logs app straight after the rebuild.

Should you put nginx or Caddy in front?

If Discourse is the only web service on the VPS, do not. The container already runs a tuned nginx, and a second proxy adds a hop, another certificate to renew and a new source of header bugs.

Front it when the same VPS serves other sites. Add templates/web.socketed.template.yml to the templates list, comment out both expose lines, and leave the two SSL templates commented out. The container then listens on a unix socket at /var/discourse/shared/standalone/nginx.http.sock and holds no ports at all, which frees 80 and 443 for your own proxy.

server {
  listen 443 ssl;
  server_name forum.example.com;

  location / {
    proxy_pass http://unix:/var/discourse/shared/standalone/nginx.http.sock:;
    proxy_set_header Host $http_host;
    proxy_http_version 1.1;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

The trailing colon after .sock is part of nginx's unix socket syntax, and sudo nginx -t refuses the config without it. X-Forwarded-Proto is not optional either. Discourse writes absolute links, so without that header it emits http:// links on an HTTPS page, and browsers block them as mixed content. With the container socketed, TLS becomes your job, so issue the certificate on the host with Certbot on Ubuntu 24.04 and nginx. If you have not settled on a proxy yet, the nginx, Caddy and Traefik comparison covers the trade you are making.

Rebuilds, upgrades and the commands you will actually use

cd /var/discourse
./launcher rebuild app

rebuild destroys the running container, bootstraps a new one from app.yml, and starts it. The site is offline for the whole build, so treat every config change as scheduled downtime of a few minutes.

Changing only values under env: does not need that. ./launcher destroy app && ./launcher start app recreates the container from the image you already built, which takes seconds. Anything under templates: or hooks: changes the image itself, so it needs the full rebuild.

Upgrades arrive two ways. Point releases are applied from the web interface at /admin/upgrade, provided by the docker_manager plugin that app.yml clones during the build. Changes to the base image or the templates come from git.

cd /var/discourse
git pull
./launcher rebuild app

Rebuilds are where small servers fail, because asset compilation is the memory peak of the whole system. A build that stops part way, with dmesg showing a line like Out of memory: Killed process naming a ruby process, ran out of memory during the build even though the site itself was running fine beforehand. Add swap and run the rebuild again.

./launcher logs app
./launcher enter app
./launcher cleanup

logs prints the container's output, enter opens a shell inside it, and cleanup removes containers that have been stopped for more than 24 hours. Run cleanup from time to time, because each rebuild leaves an old container behind and disk on a small VPS runs out quietly.

Backups, and the file the backup does not contain

Take backups from the Backups page in Admin. The archive lands on the host at /var/discourse/shared/standalone/backups/default/. The same job runs from a shell.

cd /var/discourse
./launcher enter app
discourse backup

discourse restore <filename> reverses it, and restores are refused until you run discourse enable_restore. That guard exists so a stray command cannot overwrite a live forum.

Two gaps you close yourself. The archive holds the database, and it holds uploaded files only when the backup setting that includes uploads is on, so check that setting before you trust it. It never holds app.yml, so a restore onto a fresh VPS still needs your hostname and SMTP block, which means copying that file off the box as well.

The archive also sits on the same disk as the site it protects, which is not a backup. Pull it somewhere else on a schedule.

rsync -avz root@forum.example.com:/var/discourse/shared/standalone/backups/default/ ~/discourse-backups/

What a busy forum costs in RAM

The bootstrap sets UNICORN_WORKERS and db_shared_buffers from the memory and CPU it detects, and the sample config caps shared buffers at a quarter of total memory. Each unicorn worker is a full Ruby process, and Sidekiq runs background jobs beside them, so memory use tracks concurrent requests rather than the number of registered members. A quiet forum with a few hundred members is not a heavy workload.

Do not size the server from a number in an article, including this one. Measure your own.

free -m
docker stats --no-stream

Swap in constant use together with slow pages means you are short of RAM. Steady memory with slow pages usually means something else, so read ./launcher logs app before buying a bigger plan. Add a check from outside the box too, because a forum that runs out of memory at 3am fails quietly: a self-hosted Uptime Kuma status monitor on a separate host tells you before your members do.

When Discourse is the wrong choice

Discourse is a large application with a heavy install and a rebuild cycle for every setting that lives in app.yml. That cost buys real moderation tooling and a search that still works when the archive is large. For thirty people who want somewhere to talk, it is more machine than the conversation needs. Read the comparison of self-hosted forum software first, and pick Discourse because you want what it does, not because it is the name you already knew.

FAQ

Can I install Discourse on a VPS without a domain name?

No. The shipped configuration states that Discourse will not work with a bare IP number, and DISCOURSE_HOSTNAME is required. Discourse builds absolute links from that hostname, so an IP address there breaks links and blocks certificate issuance. Create an A record before you start, and confirm with dig +short forum.example.com that it resolves to your server's address.

Do I have to configure SMTP to finish the install?

As of August 2026 you can skip it. The setup wizard offers Discourse ID logins instead, and app.yml carries a switch that skips email setup validation. For anything past a first look, configure it, because account activation and password resets both leave by mail. Use an authenticated relay on port 587 or 465, since most VPS providers block outbound port 25.

Why did my Discourse rebuild fail part way through?

Memory is the usual cause. Asset compilation during the build needs more memory than the running site, so a box that serves the forum fine can still fail to rebuild it. If dmesg shows Out of memory: Killed process naming a ruby process, add swap (the wizard's own swapfile is 2 GB) and run ./launcher rebuild app again. A build that stops on a YAML error instead points at an indentation mistake in app.yml.

Should Discourse sit behind my own nginx or Caddy?

Only when the VPS serves other sites too. Alone on a server, let the container keep ports 80 and 443 and issue its own certificate, which leaves fewer moving parts. To share the machine, add templates/web.socketed.template.yml, comment out the expose lines, and proxy to the unix socket at /var/discourse/shared/standalone/nginx.http.sock. Pass X-Forwarded-Proto through, or Discourse emits http:// links on an HTTPS page.

How do I back up a self-hosted Discourse?

Use the Backups page in Admin, or run discourse backup after ./launcher enter app. Archives land on the host at /var/discourse/shared/standalone/backups/default/. Confirm the setting that includes uploads is on, copy /var/discourse/containers/app.yml alongside the archive, and move both to another machine, because a backup on the same disk as the site does not survive the failure it exists for.