{"id":468,"date":"2026-10-08T01:30:26","date_gmt":"2026-10-08T07:00:26","guid":{"rendered":"https:\/\/photonconsole.com\/blog\/?p=468"},"modified":"2026-10-08T10:59:22","modified_gmt":"2026-10-08T16:29:22","slug":"sending-email-from-heroku-without-an-add-on","status":"publish","type":"post","link":"https:\/\/photonconsole.com\/blog\/sending-email-from-heroku-without-an-add-on\/","title":{"rendered":"Sending Email from Heroku Without an Add-On"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Signup works perfectly on your laptop. You deploy to Heroku, create an account on the live site, and the request hangs. Thirty seconds later the browser shows an application error and your log records <code>H12<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No email arrives. The obvious next move is to look for a blocked port \u2014 but Heroku is not blocking anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Heroku email problems come from the dyno, not from the network. A dyno has no mail server, no fixed outbound address, and no filesystem that survives a restart. Those three facts explain almost every failure, and none of them are fixed by changing a port.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Answer: How Do You Send Email from Heroku?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Point your application directly at an authenticated SMTP relay on port 587, store the credentials in Heroku config vars, and run the send in a worker process rather than inside the web request.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>heroku config:set \\\n  SMTP_HOST=smtp.photonrelay.com \\\n  SMTP_PORT=587 \\\n  SMTP_USER=your_project_api_user \\\n  SMTP_PASS=your_secret_api_key<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There is no local mail server to configure and no add-on required. A relay such as <a href=\"https:\/\/www.photonconsole.com\/\">PhotonConsole<\/a> is reached over the open submission port like any other outbound API call.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why Heroku Is Different From a Blocked Port<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Sending-Email-from-Heroku-1-1024x576.png\" alt=\"Three Heroku dyno constraints that break email: no local mail server, no stable outbound IP address, and the thirty second request timeout\" class=\"wp-image-470\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Sending-Email-from-Heroku-1-1024x576.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Sending-Email-from-Heroku-1-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Sending-Email-from-Heroku-1-768x432.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Sending-Email-from-Heroku-1-1536x864.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Sending-Email-from-Heroku-1.png 1672w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Heroku blocks no ports. Three properties of the dyno break email instead.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">On most hosts the problem is a firewall. On Heroku it is the runtime model.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Platform<\/th><th>What stops you sending<\/th><th>Can you change it on the host?<\/th><\/tr><\/thead><tbody><tr><td>AWS EC2<\/td><td>Outbound port 25 throttled<\/td><td>Removal request, sometimes granted<\/td><\/tr><tr><td>Google Cloud<\/td><td>Outbound port 25 blocked<\/td><td>No removal process exists<\/td><\/tr><tr><td>DigitalOcean or VPS<\/td><td>Port 25 blocked plus IP reputation<\/td><td>Port sometimes, reputation no<\/td><\/tr><tr><td>Heroku<\/td><td>No mail server and no fixed IP<\/td><td>No \u2014 there is nothing to configure<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That last row is the useful one. On Heroku you are not waiting for a support ticket. The relay is the only path, so the work is configuration rather than negotiation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Constraint 1: There is no local mail server<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The dyno image contains your application and its dependencies. It does not contain Postfix, Exim or <code>sendmail<\/code>. Any code path that assumes a local mail transfer agent has nothing to talk to.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is why PHP&#8217;s <code>mail()<\/code>, Python&#8217;s <code>sendmail<\/code> backend and shell-based mail commands fail on Heroku while working on a traditional server. The command is missing, not blocked.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Constraint 2: There is no stable outbound IP<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Stable outbound addresses are a Private Spaces feature in <a href=\"https:\/\/devcenter.heroku.com\/articles\/dyno-runtime\" target=\"_blank\" rel=\"noopener\">Heroku&#8217;s dyno runtime documentation<\/a>. On the Common Runtime, where most applications run, they are not offered.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That matters more than it first appears. SPF authorises sending by IP address or by an included domain. If your dyno&#8217;s address changes, you cannot list it in SPF, so the dyno can never be an authorised sender for your domain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sending through a relay sidesteps this entirely: you authorise the relay&#8217;s domain once, and your SPF record stays correct no matter how often dynos cycle. With PhotonConsole the SPF entry is a single <code>include<\/code> that never needs updating again.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Constraint 3: The request has thirty seconds<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Heroku router terminates any web request that takes longer than 30 seconds and writes <a href=\"https:\/\/devcenter.heroku.com\/articles\/request-timeout\" target=\"_blank\" rel=\"noopener\">error code H12<\/a> to your logs. The timeout is not configurable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An SMTP send involves a DNS lookup, a TCP connection, a TLS handshake, authentication and message transfer. On a slow network path that can exceed the window \u2014 and when it does, the user sees an error even if the mail eventually goes out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">H12 Timeout on Signup or Password Reset<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Move the send out of the web request into a background job<\/li>\n\n\n\n<li>Return the HTTP response immediately after queueing<\/li>\n\n\n\n<li>Add a worker process to your <code>Procfile<\/code> and scale it to at least one dyno<\/li>\n\n\n\n<li>Set an explicit SMTP connection timeout of 10 seconds or less<\/li>\n\n\n\n<li>Confirm with <code>heroku logs --tail<\/code> that H12 no longer appears<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: Set the Credentials as Config Vars<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Heroku exposes <a href=\"https:\/\/devcenter.heroku.com\/articles\/config-vars\" target=\"_blank\" rel=\"noopener\">config vars<\/a> to your application as environment variables. Set them all in one command, because every change restarts the app and creates a new release.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>heroku config:set \\\n  SMTP_HOST=smtp.photonrelay.com \\\n  SMTP_PORT=587 \\\n  SMTP_USER=your_project_api_user \\\n  SMTP_PASS=your_secret_api_key \\\n  MAIL_FROM=noreply@yourdomain.com\n\n# Confirm they are present on the dyno\nheroku config<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Common Mistake<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keeping the credentials in a local <code>.env<\/code> file that is correctly excluded from Git. The file never reaches the dyno, so the variables are undefined \u2014 and most mail libraries then fall back to a default host of <code>localhost<\/code> on port 25. Because no mail server is listening there, the send fails or hangs with no useful error. Config vars are the only route for secrets on Heroku; the <code>.env<\/code> file is for local development only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Setting or unsetting a config var restarts every dyno and creates a new release. Set all of them in a single command rather than one at a time, and avoid doing it during a traffic peak. Config vars are also copied into review apps and pipelines, so check that a staging app is not holding stale production credentials.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: Read Them in Your Application<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each stack reads the same four variables. Keep the values out of code entirely.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Node.js \u2014 Nodemailer\nconst transporter = nodemailer.createTransport({\n  host: process.env.SMTP_HOST,\n  port: Number(process.env.SMTP_PORT),\n  secure: false,              \/\/ STARTTLS on 587\n  auth: {\n    user: process.env.SMTP_USER,\n    pass: process.env.SMTP_PASS\n  },\n  connectionTimeout: 10000,\n  greetingTimeout: 10000\n});<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Django \u2014 settings.py\nimport os\n\nEMAIL_BACKEND = \"django.core.mail.backends.smtp.EmailBackend\"\nEMAIL_HOST = os.environ&#91;\"SMTP_HOST\"]\nEMAIL_PORT = int(os.environ&#91;\"SMTP_PORT\"])\nEMAIL_HOST_USER = os.environ&#91;\"SMTP_USER\"]\nEMAIL_HOST_PASSWORD = os.environ&#91;\"SMTP_PASS\"]\nEMAIL_USE_TLS = True\nEMAIL_TIMEOUT = 10<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Rails \u2014 config\/environments\/production.rb\nconfig.action_mailer.delivery_method = :smtp\nconfig.action_mailer.smtp_settings = {\n  address:        ENV.fetch(\"SMTP_HOST\"),\n  port:           ENV.fetch(\"SMTP_PORT\").to_i,\n  user_name:      ENV.fetch(\"SMTP_USER\"),\n  password:       ENV.fetch(\"SMTP_PASS\"),\n  authentication: :plain,\n  enable_starttls_auto: true,\n  open_timeout:   10,\n  read_timeout:   10\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Reading <code>os.environ[\"SMTP_HOST\"]<\/code> rather than a <code>.get()<\/code> with a default is deliberate. A missing variable should crash on boot where you will see it, not silently fall back to <code>localhost<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Our stack guides cover the libraries themselves in depth: <a href=\"https:\/\/photonconsole.com\/blog\/how-to-send-emails-in-node-js-with-nodemailer-a-production-setup-guide\/\">Node.js with Nodemailer<\/a>, <a href=\"https:\/\/photonconsole.com\/blog\/django-email-configuration-production\/\">Django email configuration<\/a>, <a href=\"https:\/\/photonconsole.com\/blog\/rails-actionmailer-smtp-configuration\/\">Rails ActionMailer<\/a> and <a href=\"https:\/\/photonconsole.com\/blog\/laravel-mail-configuration-production\/\">Laravel mail configuration<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: Move the Send to a Worker Dyno<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Nothing-Is-Blocked_-Dyno-Workflow-1024x576.png\" alt=\"Comparison of a synchronous Heroku web dyno send hitting the H12 timeout against a queued send handled by a worker dyno\" class=\"wp-image-471\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Nothing-Is-Blocked_-Dyno-Workflow-1024x576.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Nothing-Is-Blocked_-Dyno-Workflow-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Nothing-Is-Blocked_-Dyno-Workflow-768x432.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Nothing-Is-Blocked_-Dyno-Workflow-1536x864.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Nothing-Is-Blocked_-Dyno-Workflow.png 1672w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">The web dyno queues the job and responds. The worker dyno handles the SMTP conversation with no deadline.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This is the step most guides leave out, and it is the one that removes the H12 errors for good.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A web dyno has 30 seconds. A worker dyno has no such limit. Queue the job in the web request, return the response, and let the worker do the SMTP conversation.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Procfile\nweb: gunicorn myapp.wsgi\nworker: celery -A myapp worker --loglevel=info<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Scale the worker \u2014 it will not run otherwise\nheroku ps:scale worker=1\nheroku ps<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Django view \u2014 queue, do not send\nfrom .tasks import send_welcome_email\n\ndef register(request):\n    user = create_user(request.POST)\n    send_welcome_email.delay(user.id)   # returns immediately\n    return redirect(\"welcome\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The queue itself must live outside the dyno. The <a href=\"https:\/\/devcenter.heroku.com\/articles\/dynos\" target=\"_blank\" rel=\"noopener\">dyno filesystem is ephemeral<\/a> \u2014 anything written is discarded when the dyno stops or restarts, including automatic restarts. Use Heroku Redis or Postgres as the broker, never a file on disk.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Where the send runs<\/th><th>Time limit<\/th><th>Retries possible<\/th><th>Suitable for production<\/th><\/tr><\/thead><tbody><tr><td>Inside the web request<\/td><td>30 seconds, hard<\/td><td>No<\/td><td>No<\/td><\/tr><tr><td>Worker dyno with a queue<\/td><td>None<\/td><td>Yes, with backoff<\/td><td>Yes<\/td><\/tr><tr><td>Scheduler or one-off dyno<\/td><td>Process lifetime<\/td><td>Only on next run<\/td><td>Batch jobs only<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-queue-architecture-explained\/\">transactional email queue architecture<\/a> covers retry policy, dead-letter handling and idempotency in detail.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4: Publish Your DNS Records<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>TXT    @                    v=spf1 include:relay.photonconsole.com ~all\nCNAME  photon._domainkey    dkim.photonconsole.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Because the dyno has no fixed address, the relay&#8217;s domain is what your SPF record authorises. This is the part that makes delivery work rather than merely making the send succeed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Authentication failures are one of the most common causes of email delivery problems, and receiving providers including <a href=\"https:\/\/support.google.com\/mail\/answer\/81126\" target=\"_blank\" rel=\"noopener\">Gmail<\/a> now expect aligned SPF and DKIM on bulk and transactional mail alike.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC<\/a> explains the records, and the free <a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">email deliverability checker<\/a> shows what your domain currently publishes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5: Test From Inside the Dyno<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Testing from your laptop proves nothing about the dyno. Open a shell on the platform instead.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>heroku run bash\n\n# Confirm the variables arrived\necho $SMTP_HOST\n\n# Confirm the submission port is reachable\nnc -zvw5 smtp.photonrelay.com 587\n\n# Confirm there is no local mail server, as expected\nwhich sendmail || echo \"no local MTA \u2014 this is normal on Heroku\"<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Watch the live log while you trigger a real send\nheroku logs --tail --dyno worker<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mail Works Locally but Nothing Happens on Heroku<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Run <code>heroku config<\/code> and confirm all four SMTP variables are set<\/li>\n\n\n\n<li>Check the <code>Procfile<\/code> declares a worker and <code>heroku ps<\/code> shows it running<\/li>\n\n\n\n<li>Look for <code>localhost<\/code> or <code>127.0.0.1<\/code> in the log \u2014 that means the variables are not being read<\/li>\n\n\n\n<li>Confirm the queue broker URL is a config var, not a local Redis address<\/li>\n\n\n\n<li>Check that an Eco dyno has not gone to sleep with jobs still queued<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Why Not Just Use an Add-On?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Add-ons are convenient to install, and the trade-off is coupling. Your email provider becomes a line item on your Heroku bill, your credentials live inside the add-on&#8217;s provisioning, and your sending reputation is tied to a plan you did not configure directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add-on catalogues also change. Providers join and leave the marketplace, and a migration forced by someone else&#8217;s roadmap is a migration you did not plan for.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Configuring a relay directly takes the same four config vars and keeps the mail path independent of the host. If you later move the application to a container platform or back to a server, the email configuration moves unchanged. A <a href=\"https:\/\/www.photonconsole.com\/relay.php\">reliable SMTP provider<\/a> reached over standard credentials works the same way from any runtime.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Port Reference on Heroku<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Port<\/th><th>Purpose<\/th><th>On a Heroku dyno<\/th><\/tr><\/thead><tbody><tr><td>25<\/td><td>Server-to-server mail relay<\/td><td>Irrelevant \u2014 no local mail server exists<\/td><\/tr><tr><td>587<\/td><td>Authenticated submission<\/td><td>The standard choice<\/td><\/tr><tr><td>465<\/td><td>Submission over implicit SSL<\/td><td>Works if your library prefers it<\/td><\/tr><tr><td>2525<\/td><td>Unofficial fallback<\/td><td>Available if 587 is unreachable<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Port 587 is the submission port <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc6409\" target=\"_blank\" rel=\"noopener\">defined in RFC 6409<\/a> for authenticated clients, which is exactly what a dyno is. PhotonConsole accepts submission on 587, 465 and 2525, so a library that insists on implicit TLS needs no workaround.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pro Tips<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Always set an explicit timeout.<\/strong> Ten seconds or less. A library default of 60 seconds guarantees H12 errors the moment the network is slow.<\/li>\n\n\n\n<li><strong>Scale the worker before you test.<\/strong> A declared worker that was never scaled silently queues jobs forever.<\/li>\n\n\n\n<li><strong>Do not use an Eco dyno for mail.<\/strong> A sleeping dyno processes nothing. Worker dynos on a paid tier stay awake.<\/li>\n\n\n\n<li><strong>Keep review apps on separate credentials.<\/strong> Inherited production config vars mean test signups send real email to real addresses.<\/li>\n\n\n\n<li><strong>Log the message ID the relay returns.<\/strong> It is what turns &#8220;the user says it never arrived&#8221; into a two-minute lookup.<\/li>\n\n\n\n<li><strong>Score a real send before launch.<\/strong> <a href=\"https:\/\/www.mail-tester.com\/\" target=\"_blank\" rel=\"noopener\">Mail Tester<\/a> catches authentication problems before your users do.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Related Issues You May Hit Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-emails-failing-in-production-but-working-in-dev-a-debugging-guide\/\">Email working in dev but failing in production<\/a> \u2014 the general form of this problem<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-connection-timeout\/\">SMTP connection timeouts<\/a> when the handshake hangs rather than fails<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/aws-ec2-port-25-blocked\/\">AWS EC2 port 25 blocked<\/a> if you move the app to EC2<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/digitalocean-vps-smtp-port-25\/\">Sending email from a VPS<\/a> where the IP reputation problem appears<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/emails-sent-but-not-delivered\/\">Emails sent but not delivered<\/a> when the log reports success<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Does Heroku block SMTP ports?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Outbound connections on the submission ports work from a dyno. The obstacle is that there is no local mail server to send through, which is a different problem from a blocked port.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does my signup request time out after 30 seconds?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Heroku router enforces a hard 30-second limit and logs H12. A synchronous SMTP send inside the request can exceed it. Move the send to a worker dyno.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use PHP mail() or sendmail on Heroku?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Neither exists in the dyno image. Configure your framework&#8217;s SMTP transport with config var credentials instead.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a Heroku add-on to send email?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Any SMTP relay works with four config vars. Configuring it directly keeps the mail path independent of your hosting bill.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I add my Heroku IP to my SPF record?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not reliably. Stable outbound addresses are a Private Spaces feature, so on the Common Runtime the address changes. Authorise your relay&#8217;s domain with an SPF <code>include<\/code> instead.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Where should SMTP credentials live on Heroku?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In config vars, set with <code>heroku config:set<\/code>. A local <code>.env<\/code> file is never deployed, and the dyno filesystem does not persist.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Will my queued emails survive a dyno restart?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Only if the queue is external. Heroku restarts dynos automatically, and the filesystem is discarded each time, so use Redis or Postgres as the broker rather than a file on disk.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Heroku email failures look like network problems and are not. The platform blocks nothing. It simply gives you a container with no mail server, no fixed outbound address and a hard 30-second ceiling on web requests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once those three constraints are visible, the fix is short. Put the relay credentials in config vars, read them from the environment, move the send into a worker dyno with an external queue, and authorise the relay&#8217;s domain in SPF.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A dedicated <a href=\"https:\/\/www.photonconsole.com\/relay.php\">transactional email solution<\/a> is the natural fit for this runtime, because the only thing the dyno needs is a host, a port and a credential \u2014 no mail server to install and nothing to maintain between deploys. <a href=\"https:\/\/www.photonconsole.com\/pricing.php\">Pricing<\/a> starts with 5,000 free emails per month, enough to confirm the full path from web dyno to inbox before you spend anything.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Read More<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">Free Email Deliverability Checker<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-queue-architecture-explained\/\">Transactional Email Queue Architecture Explained<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-emails-failing-in-production-but-working-in-dev-a-debugging-guide\/\">Emails Failing in Production but Working in Dev<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC Explained Simply<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-configuration\/\">SMTP Configuration: Complete Setup Reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay: SMTP Relay Service<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/pricing.php\">PhotonConsole Pricing<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Signup works perfectly on your laptop. You deploy to Heroku, create an account on the live site, and the request hangs. Thirty seconds later the browser shows an application error and your log records H12. No email arrives. The obvious next move is to look for a blocked port \u2014 but Heroku is not blocking [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":469,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-468","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-email-deliverability"],"_links":{"self":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/468","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/comments?post=468"}],"version-history":[{"count":1,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/468\/revisions"}],"predecessor-version":[{"id":472,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/468\/revisions\/472"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media\/469"}],"wp:attachment":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media?parent=468"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/categories?post=468"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/tags?post=468"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}