TL;DR (What we’ll build)#
By the end, you’ll have:
- A
m7i.xlargeEC2 instance (Ubuntu 24.04) provisioned through Forge with Nginx + Let’s Encrypt. - Sentry Self-Hosted 25.x running via Docker Compose, correctly bound behind your Forge Nginx reverse proxy with SSL.
- Swap (16 GB) configured to avoid Docker “cannot allocate memory” and similar OCI runtime issues under load.
- MaxMind GeoIP working, system.url-prefix set, and Docker bound to 127.0.0.1:9000 (not the public internet).
- Slack and GitHub integrations fully wired (with GitHub App and correct Setup URL so the install dialog actually completes).
- Mailgun SMTP for invites and alerts.
- Laravel server SDK + Browser JS tracing snippet (performance & optional replay).
- GitHub Copilot MCP connected to your self-hosted Sentry so Copilot can query your issues/projects with an auth token.
Why this setup?#
- EC2 (x86_64 m7i) avoids container/runtime constraints seen on some VPS virtualization stacks.
- Forge gives you quick Nginx + SSL and a tidy place to manage the reverse proxy.
- Docker Compose matches Sentry’s official self-hosted approach.
- Slack/GitHub integrations bring actionability (alerts, linking code, issue automation).
- Mailgun enables invites, password resets, and alert emails.
- MCP lets GitHub Copilot query your Sentry directly (super useful for triage).
Prerequisites#
- A domain you control (we’ll use sentry.example.com below; replace with your real domain).
- An AWS EC2 instance (we used m7i.xlarge, 4 vCPU / 16 GB RAM) running Ubuntu 24.04.
- Provision the instance using Forge as a Load Balancer (Forge installs Nginx + cert tooling).
- SSH access as a sudo-enabled user (we’ll use forge) and root as needed.
1) System prep + Swap (16 GB)#
Run as root on the EC2 box.
# See current disks and mounts
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS
# If a small swapfile exists, turn it off (optional)
swapoff -a
# Create a 16 GB swapfile
dd if=/dev/zero of=/swapfile bs=1M count=16384 status=progress
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
# Persist across reboots
grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
# Verify
free -h
swapon --show
Expected output (example):
Swap: 15Gi 0B 15Gi
NAME TYPE SIZE USED PRIO
/swapfile file 16G 0B -2
2) Install Docker & Docker Compose#
Run as root:
apt-get update -y
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
| tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
Give your non-root user (forge) permission to run Docker:
usermod -aG docker forge
loginctl terminate-user forge || true
Re-SSH as forge and verify:
docker run --rm hello-world
docker info --format 'Hello {{.Name}}; Server={{.ServerVersion}}; CgroupDriver={{.CgroupDriver}}'
Expected output snippet:
Hello Sentry; Server=28.x; CgroupDriver=systemd
3) Fetch Sentry Self-Hosted and Install#
Run as forge:
cd ~
git clone https://github.com/getsentry/self-hosted.git
cd self-hosted
# Pin to latest release tag
LATEST_TAG=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/getsentry/self-hosted/releases/latest)
LATEST_TAG=${LATEST_TAG##*/}
git checkout "${LATEST_TAG}"
git describe --tags # should print something like 25.8.0
(Optional) Configure MaxMind GeoIP (recommended)
cat > geoip/GeoIP.conf <<'CFG'
AccountID YOUR_MAXMIND_ACCOUNT_ID
LicenseKey YOUR_MAXMIND_LICENSE_KEY
EditionIDs GeoLite2-City
CFG
Run the helper (or it runs during install):
./install/geoip.sh
Expected output (condensed):
Setting up IP address geolocation ...
... ghcr.io/maxmind/geoipupdate:v6.x ...
Done updating IP address geolocation database.
Install Sentry
./install.sh
# After it completes:
docker compose up --wait
Expected: dozens of containers, and web, nginx, worker show (healthy).
4) Bind Sentry to localhost & configure Nginx (Forge)#
We’ll keep Sentry’s internal Nginx bound to 127.0.0.1:9000 and use Forge Nginx as the public SSL proxy.
A) Bind the Sentry container port to localhost
Run as forge in ~/self-hosted:
cat > docker-compose.override.yml <<'YML'
services:
nginx:
ports:
- "127.0.0.1:9000:80"
YML
docker compose up -d nginx
docker compose ps nginx
Expected PORTS: 127.0.0.1:9000->80/tcp
B) Set Sentry URL prefix
DOMAIN="sentry.example.com" # <-- change me
test -f sentry/config.yml || touch sentry/config.yml
if grep -q '^system.url-prefix:' sentry/config.yml; then
sed -i.bak -E "s|^system\.url-prefix:.*|system.url-prefix: https://${DOMAIN}|" sentry/config.yml
else
printf "\nsystem.url-prefix: https://${DOMAIN}\n" >> sentry/config.yml
fi
C) Forge Nginx upstream → 127.0.0.1:9000
On Forge servers, the upstream lives under /etc/nginx/upstreams/<yourdomain>. Run as root:
cat >/etc/nginx/upstreams/sentry.example.com <<'NGX'
upstream 2844682_app {
server 127.0.0.1:9000;
keepalive 64;
}
NGX
# Add some sane proxy limits (Forge includes these directories)
mkdir -p /etc/nginx/forge-conf/sentry.example.com/server
cat >/etc/nginx/forge-conf/sentry.example.com/server/sentry-proxy.conf <<'NGX'
client_max_body_size 100m;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
NGX
nginx -t && systemctl reload nginx
Note: Check the name of the upstream app (here 2844682_app) matches your Forge server’s upstream file.
D) Health check (host + domain)
curl -I http://127.0.0.1:9000/_health/
curl -I https://sentry.example.com/_health/
Expected: HTTP/1.1 200 OK and HTTP/2 200
5) First-run admin + login#
The installer asks for an admin user; if you missed it, create via:
# inside the web container
docker compose exec web sentry createuser
Then login at https://sentry.example.com with that user.
6) Email (Mailgun SMTP)#
Add these to ~/self-hosted/sentry/config.yml (forge user):
# Email via Mailgun SMTP
mail.backend: 'smtp'
mail.host: 'smtp.mailgun.org' # or 'smtp.eu.mailgun.org' for EU region
mail.port: 587
mail.username: 'postmaster@mg.yourdomain.com'
mail.password: 'YOUR_MAILGUN_SMTP_PASSWORD'
mail.use-tls: true
mail.use-ssl: false
mail.from: 'sentry@yourdomain.com'
Apply:
docker compose restart web worker cron
Test email by inviting a new user via the Sentry UI.
7) Slack Integration (self-hosted)#
Create a Slack App (api.slack.com/apps → Create New App → From scratch):
- Redirect URL: https://sentry.example.com/extensions/slack/setup/
- Interactivity Request URL: https://sentry.example.com/extensions/slack/actions/
- Bot Token Scopes (minimum useful):
chat:write
chat:write.public
channels:read
groups:read
im:read
mpim:read
links:read
links:write
commands
Add credentials to Sentry sentry/config.yml:
slack.client-id: "YOUR_SLACK_CLIENT_ID"
slack.client-secret: "YOUR_SLACK_CLIENT_SECRET"
slack.signing-secret: "YOUR_SLACK_SIGNING_SECRET"
Restart & install:
docker compose restart web worker
# In Sentry: Settings → Integrations → Slack → Add → select workspace
Create an alert rule and choose Send a Slack notification to test.
8) GitHub Integration (GitHub App)#
Create a GitHub App (Org Settings → Developer settings → GitHub Apps → New):
- Homepage URL: https://sentry.example.com/
- Callback URL: https://sentry.example.com/extensions/github/setup/
- Setup URL: https://sentry.example.com/extensions/github/setup/ ← don’t skip this
- Webhook → Active
- Webhook URL: https://sentry.example.com/extensions/github/webhook/
- Webhook secret: (generate one)
Permissions (common set):
- Repository Contents: Read & write
- Pull requests: Read & write
- Issues: Read & write
- Metadata: Read-only
Save, then Generate a private key (.pem). Note:
- App ID (integer)
- Client ID and Client Secret
- Add credentials to Sentry
sentry/config.yml:
github-app.id: 123456 # <-- integer, no quotes
github-app.client-id: "Iv1_xxx"
github-app.client-secret: "ghs_xxx"
github-app.webhook-secret: "random-string-you-set"
github-app.private-key: |
-----BEGIN RSA PRIVATE KEY-----
(paste the full PEM here)
-----END RSA PRIVATE KEY-----
Apply & install:
docker compose restart web worker
# In Sentry: Settings → Integrations → GitHub → Add Integration → pick org → Install
If the popup doesn’t close after installing on GitHub, you most likely forgot the Setup URL.
Add repositories in Sentry → GitHub → Configure.
Code Mappings (per project): Project → Settings → Integrations → GitHub → Configure → Add Code Mapping
Choose repository, default branch (e.g., main), and Source Root matching your stack trace paths (e.g., src/).
9) Laravel Backend SDK + Browser JS Tracing#
A) Laravel Backend
Install the sentry/sentry-laravel package:
composer require sentry/sentry-laravel
Enable capturing unhandled exception to report to Sentry by making the following change to your bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Sentry\Laravel\Integration;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
Integration::handles($exceptions);
})->create();
Configure SDK
When you create a project in Sentry, you’ll get a DSN. Like the follwowing:
php artisan sentry:publish --dsn=https://UNIQUE_PROJECT_KEY@sentry.example.com/1
It creates the config file (config/sentry.php) and adds the DSN to your .env file where you can add further configuration options:
SENTRY_LARAVEL_DSN=https://UNIQUE_PROJECT_KEY@sentry.example.com/1
Browser JS (frontend tracing + optional Replay)
Similar to backend setup, inside Sentry project settings, retrieve the Loader Script snippet and add it to your main HTML template (e.g., resources/views/layouts/app.blade.php):
<script src="https://sentry.example.com/js-sdk-loader/UNIQUE_PROJECT_FILE.min.js" crossorigin="anonymous"></script>
10) GitHub Copilot MCP → Your Self-Hosted Sentry#
This lets Copilot query your self-hosted Sentry via MCP.
Create a user auth token in Sentry: Avatar → User Settings → Auth Tokens → Create Token
Scopes you likely want:
org:read, project:read, event:write (optionally project:write if you want to create releases via MCP).
Add the MCP server to Copilot config (e.g., VS Code → Settings JSON):
{
"mcpServers": {
"sentry": {
"command": "npx",
"args": ["-y", "@sentry/mcp-server@latest"],
"env": {
"SENTRY_HOST": "https://sentry.example.com",
"SENTRY_ACCESS_TOKEN": "${secrets.COPILOT_MCP_SENTRY_ACCESS_TOKEN}",
"SENTRY_ORG": "your-org-slug", // optional
"SENTRY_PROJECT": "your-project-slug" // optional
}
}
}
}
Store the token as a Copilot secret:
Command Palette → Copilot: Manage Secrets →
COPILOT_MCP_SENTRY_ACCESS_TOKEN = <your token>
Local quick test (optional):
npx @sentry/mcp-server@latest \
--host=https://sentry.example.com \
--access-token=YOUR_TOKEN
Now ask Copilot:
“Use the sentry MCP server to list my projects”
“Find issue by ID …”
11) Security & hardening tips#
- Keep Sentry’s Docker Nginx bound to 127.0.0.1:9000 only (we did).
- Use Let’s Encrypt in Forge (already done) and consider enabling HSTS (Forge SSL settings).
- Regularly
git checkoutthe latest self-hosted tag and run the upgrade:
cd ~/self-hosted
git fetch --tags
NEW=$(git tag | sort -V | tail -n1); echo $NEW && git checkout $NEW
./install.sh && docker compose up --wait
12) Troubleshooting notes#
502 Bad Gateway after editing config
docker compose ps web nginx → if web is “restarting,” run docker logs --tail=200 sentry-self-hosted-web-1.
YAML type errors are common (e.g., github-app.id must be an integer, not "123456").
GitHub popup never closes
You forgot Setup URL (/extensions/github/setup/) in the app config.
Mail not sending on EC2
Use 587/TLS, not 25. Verify from server:
nc -vz smtp.mailgun.org 587
OpenVZ / nested virt errors
If you previously saw Docker “OCI runtime create failed” on some VPSs: moving to EC2 (KVM/metal) fixes it.
Seer/Autofix
Sentry’s AI features are SaaS-only. Don’t rely on the UI flag in self-hosted.
Conclusion#
You now have a production-ready Sentry Self-Hosted stack on AWS EC2 with Forge managing SSL + Nginx, Slack and GitHub integrations working end-to-end, Mailgun for email, Laravel + Browser tracing sending data, and MCP wired so Copilot can query your self-hosted Sentry.
If you found this useful, share it with your team, this exact recipe saves hours for anyone bootstrapping Sentry self-hosted with real-world integrations and developer tooling.
Once Sentry is live, two things will make your error signal cleaner and more actionable. First, blocking malicious 404s with NGINX before they reach Laravel stops bot traffic from inflating your error counts and burning through monitoring quota. Second, for custom application-level metrics that go beyond exception tracking, building custom Laravel Pulse recorders lets you surface domain events — payment rates, API quota consumption — in a dashboard alongside Sentry's error feed. Sentry and Pulse complement each other: Sentry for exceptions and user impact, Pulse for application health metrics. For the deployment pipeline that puts your Forge server into production, see Zero-Downtime Laravel Deployments with GitHub Actions and Forge.
FAQ#
Why use m7i.xlarge instead of a smaller instance or a managed service like Sentry Cloud?
Self-hosted Sentry is memory-hungry under production load — the m7i.xlarge's 16 GB provides headroom for Docker containers, PostgreSQL, Redis, and the web workers simultaneously. Smaller instances (t3.xlarge) hit OCI runtime errors and become CPU-bottlenecked during bulk event ingestion. Self-hosting costs $60–100/month in infrastructure versus Sentry Cloud's higher monthly per-event pricing, which pays off quickly if you're capturing thousands of events per day.
What does setting system.url-prefix accomplish, and why is it mandatory?
system.url-prefix tells Sentry where it's running so it can generate correct links in emails, alerts, and the UI. Without it, email invites and Slack notifications link back to localhost or your internal IP, which fails for external users. This is also required for OAuth apps (GitHub, Slack) to validate redirect URLs — if the prefix is wrong, the install callback fails silently.
Why bind Sentry's Nginx to 127.0.0.1:9000 instead of letting it listen on 0.0.0.0?
Binding to 127.0.0.1 means only the local machine can access Sentry's Nginx. Your Forge Nginx proxy (listening on 0.0.0.0:443) is the only public-facing service. This gives you a clean security boundary — Sentry never touches the public internet directly, all traffic goes through Forge's SSL termination and firewall rules. If someone finds a Sentry vulnerability, they can't reach it without already being on your server.
If GitHub App setup works but the popup doesn't close, what went wrong?
You forgot the Setup URL field (https://sentry.example.com/extensions/github/setup/) in the GitHub App config. The OAuth callback expects a Setup URL that's the same as (or a redirect to) your Callback URL. Without it, GitHub doesn't know where to redirect after authorization, so the popup hangs. This is the most common mistake when configuring GitHub integrations.
How do I handle migrations safely in Kubernetes without race conditions?
Don't run migrations in the container startup script — if two replicas boot simultaneously, both run migrations at the same time causing deadlocks. Instead, use a Kubernetes Job that runs once before the Deployment scales up, or use an init container that runs before the main container starts. The Job pattern is cleaner: create a job.yaml with the same image and override the command to ["php", "artisan", "migrate", "--force"], then apply the Job before rolling out the Deployment.
Conclusion#
You now have a production-ready Sentry Self-Hosted stack on AWS EC2 with Forge managing SSL + Nginx, Slack and GitHub integrations working end-to-end, Mailgun for email, Laravel + Browser tracing sending data, and MCP wired so Copilot can query your self-hosted Sentry.