Skip to content
All articles
7 min read

Postgres "Password Authentication Failed": Fixes

MD Rakibul Islam RakibMD Rakibul Islam RakibFull-stack developer, DevOps & Linux engineer
Postgres "Password Authentication Failed": Fixes

"Password authentication failed for user" means Postgres rejected the login: a wrong password, a user without one, or a pg_hba.conf rule that doesn't match.

This error stops a deploy cold. The app can't reach its data, migrations won't run, and the message gives you almost nothing to work with. I run PostgreSQL behind the NestJS and Prisma API that powers this site, and I've chased this error on fresh VPS installs, in Docker Compose stacks and after password changes. It always comes down to one of six things, and the Postgres server log tells you which. Here is the order I check them in.

Key takeaways

  • Check the server log, not only the app's error. Postgres logs a DETAIL line that says whether the user has no password, the password didn't match, or no pg_hba.conf rule matched.
  • A fresh install has no password for postgres. Local logins use "peer" authentication. Set a password before connecting over TCP.
  • Special characters in a URL password (@ : / # %) must be percent-encoded in DATABASE_URL, or the client sends the wrong password.
  • Docker ignores POSTGRES_PASSWORD after the first start. The password lives in the data volume.
  • Peer authentication failed is a different error with a different fix: connect with -h 127.0.0.1 or change the rule.

The error and its cousins

FATAL:  password authentication failed for user "app"
FATAL:  Peer authentication failed for user "postgres"
FATAL:  no pg_hba.conf entry for host "172.18.0.5", user "app", database "app", no encryption

Prisma shows the first one as P1000: Authentication failed against database server; node-postgres, Django and Rails show the Postgres text directly. The fixes are the same.

Step 1: Read the Postgres log

The client only gets a short message on purpose, so attackers learn nothing. The server log has the detail:

sudo tail -n 30 /var/log/postgresql/postgresql-*-main.log     # Ubuntu / Debian
docker compose logs db --tail 30                               # Docker
FATAL:  password authentication failed for user "app"
DETAIL:  User "app" has no password assigned.
DETAIL:  Connection matched file "/etc/postgresql/16/main/pg_hba.conf" line 125: "host all all 127.0.0.1/32 scram-sha-256"

"has no password assigned", "Password does not match", and "Role does not exist" each point to a different cause below. The second DETAIL line tells you which pg_hba.conf rule handled the login, which saves a lot of guessing.

Cause 1: The user has no password (fresh install)

On Ubuntu and Debian, the postgres superuser is created without a password and logs in locally through "peer" authentication: your Linux username must match the database role. So this works:

sudo -u postgres psql

but any TCP login, which is what your app uses, fails. Create a dedicated user and database for the app instead of using postgres:

sudo -u postgres psql
CREATE ROLE app WITH LOGIN PASSWORD 'use-a-long-random-password';
CREATE DATABASE app OWNER app;
\q

Generate the password with openssl rand -hex 24. Hex has no characters that need escaping in a URL, which avoids cause 3.

Cause 2: The password really is wrong

Test the exact credentials from the app's environment, from the same machine the app runs on:

psql "postgresql://app:THE_PASSWORD@127.0.0.1:5432/app" -c 'select 1'

If this fails too, reset it and update the app's .env in the same step:

sudo -u postgres psql -c "ALTER ROLE app WITH PASSWORD 'new-password';"

Then restart the app so it reads the new value. A Node process under PM2 keeps the old environment until you run pm2 restart app --update-env.

login: app @ 127.0.0.1 (TCP) pg_hba.conf, first match wins local all all peernot TCP host all all ::1/128 scram-sha-256not IPv4 host all all 127.0.0.1/32 scram-sha-256 match ✓ No matching line at all = "no pg_hba.conf entry"
Postgres reads pg_hba.conf from the top and uses the first rule that matches the connection type, database, user and address. Only then does it check the password, so a wrong rule and a wrong password produce different errors.

Cause 3: Special characters in DATABASE_URL

This one wastes hours. If the password contains @, :, /, #, ? or %, the URL parser splits it in the wrong place and the client sends only part of the password. Percent-encode it:

node -e 'console.log(encodeURIComponent(process.argv[1]))' 'p@ss:w/rd#1'
# p%40ss%3Aw%2Frd%231
DATABASE_URL="postgresql://app:p%40ss%3Aw%2Frd%231@127.0.0.1:5432/app"

Also watch for quotes and trailing spaces copied into .env, and a $ that your shell or Docker Compose tries to expand. In Compose, write a literal dollar sign as $$.

Cause 4: Docker kept the old password

The official postgres image reads POSTGRES_PASSWORD only when it initialises an empty data folder. Change the variable later and nothing happens, because the password is already stored in the volume. Either change it inside the database:

docker compose exec db psql -U postgres -c "ALTER ROLE postgres WITH PASSWORD 'new-password';"

or, only on a throwaway dev database, remove the volume and start fresh with docker compose down -v. Never run down -v on production: it deletes the data. My Docker Compose production checklist covers volumes and backups.

Inside Compose, the app must connect to the service name, not localhost: postgresql://app:pw@db:5432/app. With localhost you get a connection error instead, which my P1001 guide covers.

Cause 5: pg_hba.conf doesn't allow the connection

"no pg_hba.conf entry for host" means no rule matched at all. "Peer authentication failed" means a local rule with peer matched a socket login. Find the file and read the active rules:

sudo -u postgres psql -c 'SHOW hba_file;'
sudo grep -vE '^\s*(#|$)' /etc/postgresql/16/main/pg_hba.conf

For an app on the same server, connecting over TCP with 127.0.0.1 already matches the default scram-sha-256 rule. For an app in Docker or on another server, add a narrow rule for its network, not 0.0.0.0/0:

host  app  app  10.0.0.0/24  scram-sha-256

Then reload: sudo systemctl reload postgresql. Rules are checked top to bottom, so put specific ones above broad ones.

Cause 6: md5 vs scram-sha-256

Since PostgreSQL 14 the default is password_encryption = scram-sha-256. A password set years ago may still be stored as an MD5 hash. If a rule demands scram-sha-256, that old hash can't be used and the login fails even with the right password. Set the password again on a current server and it's stored as SCRAM. Very old client libraries that don't support SCRAM need upgrading; every current Node, Python and PHP driver does.

Lock it down while you're here

  • One role per app, owning only its database. Don't run apps as postgres.
  • Keep port 5432 closed to the internet. Connect remote tools through an SSH tunnel. My Ubuntu hardening checklist includes the firewall rules.
  • Watch connection counts once auth works. The next common error is "too many clients already".

Frequently asked questions

What is the default password for the postgres user?

There isn't one. On Ubuntu and Debian the postgres role has no password and logs in locally through peer authentication. Run sudo -u postgres psql and set one with ALTER ROLE if you need TCP logins.

What's the difference between peer and password authentication?

Peer authentication trusts the Linux user that connects through the local socket and requires it to match the database role name. Password authentication (scram-sha-256 or md5) checks a password and is what apps use over TCP.

Why does psql work but my app gets "password authentication failed"?

psql probably connects through the Unix socket with peer authentication, while the app connects over TCP with a password. Test the app's exact URL with psql "postgresql://..." to reproduce what the app sees.

Do I need to restart Postgres after editing pg_hba.conf?

No, a reload is enough: sudo systemctl reload postgresql or SELECT pg_reload_conf();. Changing listen_addresses in postgresql.conf is different and needs a full restart.

How do I fix Prisma P1000 authentication failed?

P1000 is this same Postgres error reported by Prisma. Check the server log's DETAIL line, test the URL with psql, and percent-encode special characters in the password inside DATABASE_URL.

Stuck on a database that won't connect?

I build and fix Node.js and NestJS backends on PostgreSQL, from first connection to production pooling and backups. See my backend API development service, all web development services, or send me the log line and I'll tell you what's wrong.

MD Rakibul Islam Rakib

Written by

MD Rakibul Islam Rakib

Full-stack developer, DevOps engineer and Linux system administrator with 5+ years of production experience. I deploy, harden and fix servers and web apps for clients worldwide, and everything in this article runs on real servers I manage, including this site.

  • password authentication failed for user
  • PostgreSQL
  • pg_hba.conf
  • Peer authentication failed
  • DATABASE_URL
  • Prisma P1000
  • Docker Postgres

Keep reading

WordPress to Next.js Migration Without Losing SEO
Website DesignOct 9, 2026

WordPress to Next.js Migration Without Losing SEO

Moving WordPress to Next.js makes a site faster and safer, but only if you keep every URL, redirect old ones with 301s and carry over titles and meta tags. Business owners usually ask me about this after one of three things: the WordPress site got hacked, it fails Core Web Vitals no matter how many caching plugins they add, or the plugin and hosting bills keep growing. Next.js fixes all three well. This site is a Next.js 16 app, and I've rebuilt WordPress sites this way. But a migration done carelessly can wipe out years of Google rankings in a week. This guide covers when it's worth it, the two ways to do it, and the SEO steps that keep your traffic. Key takeaways Migrate for a reason: speed, security, custom features or cost. If your team lives in the WordPress editor and the site is fine, a headless setup or a tune-up may be better. Two paths: headless (keep WordPress as the editor, Next.js as the website) or full move (content goes into Markdown, a headless CMS or your own database). Crawl the old site first and keep a list of every URL. That list is your redirect map and your checklist. Keep URLs identical where you can. Where they must change, add one 301 redirect per old URL, never a blanket redirect to the homepage. Carry over titles, meta descriptions, alt text and structured data , then watch Search Console for a few weeks. Is it worth moving off WordPress? Good reasons to migrate: Speed. A Next.js site sends pre-rendered HTML and only the JavaScript a page needs. Page-builder themes often load large CSS and script bundles on every page. Faster pages help rankings and conversions; see my guides on fixing slow LCP and INP . Security. Most WordPress hacks I've cleaned up came through an outdated plugin or theme. A Next.js site has no public admin login and no plugin folder to attack. Custom features. Booking, dashboards, customer portals and integrations are normal code in Next.js, not a stack of plugins fighting each other. Running costs. No premium plugin licences to renew, and a fast site runs on a small VPS. My hosting cost comparison shows the options. Reasons to wait: non-technical editors who rely on the WordPress editor daily, a WooCommerce shop with many extensions you'd have to replace, or a site that already performs well. Changing technology doesn't fix weak content or an unclear offer. Path 1: Headless WordPress WordPress stays where your team writes, and Next.js becomes the public website. Next.js reads posts and pages from the built-in REST API ( /wp-json/wp/v2/posts ) or from the WPGraphQL plugin: // app/blog/[slug]/page.tsx import { notFound } from 'next/navigation'; const WP = 'https://cms.example.com/wp-json/wp/v2'; export default async function Post({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const res = await fetch(`${WP}/posts?slug=${slug}&_embed`, { next: { revalidate: 300 } }); const [post] = await res.json(); if (!post) notFound(); return <article dangerouslySetInnerHTML={{ __html: post.content.rendered }} />; } Editors keep their workflow, and the slow, plugin-heavy front end disappears. Move WordPress to a subdomain such as cms.example.com , block it from search engines, and protect its login. The catch: you now run two systems, and page-builder layouts don't come across, because Next.js renders the content, not the builder. Path 2: Full migration Export the content once and switch WordPress off. Content goes into Markdown or MDX files in the repo (great for small, developer-run sites), a headless CMS such as Sanity, Strapi or Payload, or your own database with an admin dashboard, which is how this site works. Pull everything through the REST API with a script, convert the HTML, download the media, and keep each post's slug, date, title, excerpt and SEO fields. You end up with one system, fewer moving parts, and nothing left to patch every week. Every URL from the old site needs a destination: the same path, or a 301 redirect to its new address. Old URLs left without one return 404s, and the rankings they earned disappear with them. The SEO checklist that protects your rankings 1. Inventory every URL before you touch anything Export the old site's URLs from three places: the XML sitemap (Yoast and Rank Math put it at /sitemap_index.xml ; core WordPress at /wp-sitemap.xml ), a crawl with a tool like Screaming Frog, and Search Console's Pages and Performance reports. Mark the pages that get traffic or backlinks. Those must not break. 2. Keep the same URLs Next.js routes can match almost any WordPress structure. If posts lived at /my-post/ , use an app/[slug]/page.tsx route. If you want cleaner URLs, change them on purpose and redirect, not by accident. Watch trailing slashes: WordPress adds them, Next.js doesn't by default. Set trailingSlash: true in next.config.ts if you keep the old style. 3. Redirect what changes // next.config.ts export default { async redirects() { return [ { source: '/:year(\\d{4})/:month(\\d{2})/:slug', destination: '/blog/:slug', permanent: true }, { source: '/category/:cat', destination: '/blog?topic=:cat', permanent: true }, { source: '/feed', destination: '/rss.xml', permanent: true }, ]; }, }; permanent: true sends a 308, which search engines treat like a 301. For hundreds of one-off URLs, keep them in a map file or do them in Nginx. Never redirect everything to the homepage. Google treats that as a soft 404 and drops the rankings anyway. 4. Carry over the SEO data Export the title and meta description from your SEO plugin for every page, and set them with generateMetadata . Keep image alt text, canonical tags, Open Graph images, and structured data such as Article, FAQ, Product and LocalBusiness. Generate a sitemap with app/sitemap.ts and a robots.ts . 5. Don't forget the hidden features Contact forms (and their spam protection and email delivery, see emails going to spam ), the search box, comments, RSS, analytics, cookie consent and newsletter embeds. List every plugin and decide what replaces each one. 6. Launch, then watch Switch DNS when traffic is lowest, keep the old server running for a few days, then submit the new sitemap and test key URLs in Search Console. Crawl the old URL list against the new site and expect a 200 or a single 301 for every one, never a 404 or a redirect chain. My zero-downtime migration guide covers the DNS cutover, and redesign without losing SEO has the full checklist. Frequently asked questions Is Next.js better than WordPress for SEO? Next.js makes it easier to build fast pages with clean HTML, which helps Core Web Vitals. But rankings come from content, links and technical basics. A well-optimised WordPress site can outrank a careless Next.js one, so the migration must preserve URLs and metadata. Will I lose rankings when moving from WordPress to Next.js? Not if you keep URLs the same or 301-redirect every changed one, keep titles and meta descriptions, and keep the content. A short wobble for a few weeks is normal while Google recrawls. Big drops come from missing redirects. Can I keep using WordPress as a CMS with Next.js? Yes. That's headless WordPress: editors use the normal admin, and Next.js reads content through the REST API or WPGraphQL and renders the public site. Keep the WordPress admin off the public domain and updated. How long does a WordPress to Next.js migration take? A small business site of 10 to 30 pages is usually a few weeks including design, content transfer, redirects and testing. Large blogs, multilingual sites and WooCommerce shops take longer because there's more content and functionality to rebuild. What about WooCommerce? You can run WooCommerce headless or move to a commerce platform with an API, but checkout, payments, shipping and extensions all need to be rebuilt or replaced. Plan and price that part separately; it's usually most of the work. Thinking about leaving WordPress? I rebuild WordPress sites in Next.js with every URL, redirect and meta tag carried over, so you get the speed without losing your Google traffic. See my business website design and development service, all website design services , or send me your site for an honest opinion on whether it's worth it.

Read article →
Website "Not Secure" With SSL? Fix Mixed Content
Website DesignOct 9, 2026

Website "Not Secure" With SSL? Fix Mixed Content

A site that shows "Not secure" with a valid SSL certificate is loading something over http://. Find it in the browser console, then switch those URLs to https. You installed an SSL certificate, the address starts with https:// , and the browser still shows "Not secure" or a broken padlock. Visitors notice. Some leave, and a contact or checkout form that looks unsafe loses leads. This is almost always mixed content : the page itself is secure, but it loads an image, script, font or form target over plain http:// . I fix this during server moves and redesigns all the time, and it takes three steps: find the insecure URLs, fix them at the source, then add a safety net so new ones can't slip in. Key takeaways Open DevTools (F12) → Console. Every insecure resource is listed as a "Mixed Content" warning with its exact URL. Scripts, stylesheets and iframes over http are blocked , which can break menus, sliders and forms, not only the padlock. The real fix is changing http:// to https:// where the URL lives: the database, theme files, CSS, or a hard-coded embed. A form that submits to http:// makes Chrome warn on submit. Check every form's action . Add upgrade-insecure-requests as a safety net, and redirect all http traffic to https. Step 1: Find every insecure resource Open the page in Chrome, press F12, and go to the Console tab. Reload. Mixed content shows up like this: Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure script 'http://example.com/wp-content/plugins/slider/slider.js'. This request has been blocked; the content must be served over HTTPS. The Security tab in DevTools gives a summary for the page. Check more than the homepage: blog posts, the shop, the contact page and checkout often load different files. To scan from a terminal, search the raw HTML for http:// in the attributes that load things: curl -s https://example.com/ | grep -oE '(src|href|action|srcset)="http://[^"]+"' | sort -u curl -s https://example.com/wp-content/themes/mytheme/style.css | grep -o 'url(http://[^)]*' | sort -u Plain links to other sites ( <a href="http://..."> ) don't cause mixed content, though it's still worth updating them. What matters is anything the page loads : images, scripts, styles, fonts, iframes, video, and form targets. Why browsers treat it differently Browsers split mixed content into two kinds. For images, audio and video, Chrome first tries to load the same URL over https automatically, and only blocks it if that fails. Scripts, stylesheets, iframes and fetch requests are blocked outright, because an attacker on the network could change them and take over the page. That's why mixed content can do more than remove the padlock: a blocked jQuery or stylesheet can break the whole layout. Browsers upgrade insecure images and media to https when they can, but block insecure scripts, styles and iframes completely. One http script can remove the padlock and break a menu or a form at the same time. Step 2: Fix the URLs at the source WordPress Old http:// URLs live in the database: in post content, widget settings, theme options and page builder data. First set both addresses under Settings → General to https:// . Then replace the rest with WP-CLI, which handles serialized data safely (a plain SQL replace can corrupt it): wp db export before-https.sql # backup first wp search-replace 'http://example.com' 'https://example.com' --skip-columns=guid --dry-run wp search-replace 'http://example.com' 'https://example.com' --skip-columns=guid wp cache flush Run the dry run first and read the counts. If you use a caching plugin or a CDN, purge it afterwards, or visitors keep seeing the old HTML. Hard-coded URLs in themes, CSS and templates Search the code for http:// and change your own domain's URLs to https or to relative paths ( /images/logo.png ): grep -rn "http://" --include=*.{php,html,css,js,tsx} ./theme ./src | grep -v "http://www.w3.org" For third-party files (an old widget, font or analytics script), switch to the provider's https URL. If a provider doesn't support https at all in 2026, replace it. It's also a security risk. Forms Check the action of every form, including newsletter embeds. A form posting to http:// makes Chrome show a warning when the visitor submits, which kills conversions on contact and checkout pages. Step 3: "Not secure" but no mixed content? If the console is clean, look at these instead: Your app thinks it's on http. Behind Nginx, a load balancer or Cloudflare, the app sees plain http and builds http:// links. Pass proxy_set_header X-Forwarded-Proto $scheme; in Nginx and make the app trust it. In WordPress behind a proxy, check $_SERVER['HTTPS'] handling in wp-config.php . Cloudflare "Flexible" SSL. Browser to Cloudflare is encrypted, Cloudflare to your server isn't, and it often causes redirect loops. Install a certificate on the origin and use "Full (strict)". My too many redirects guide covers this. The certificate itself. Expired, issued for a different name (www vs non-www), or missing its chain. Click the padlock area to see the reason, and see Certbot renewal failed if it expired. Step 4: Add a safety net Redirect all http traffic to https with one 301, and tell browsers to upgrade any leftover http requests. In Nginx: server { listen 80; server_name example.com www.example.com; return 301 https://www.example.com$request_uri; } # inside the https server block: add_header Content-Security-Policy "upgrade-insecure-requests" always; add_header Strict-Transport-Security "max-age=31536000" always; upgrade-insecure-requests makes the browser fetch every http:// resource on the page over https. It hides problems rather than fixing them, so do it after step 2, not instead of it. HSTS tells browsers to always use https for your domain. Start with a short max-age if you're not sure every subdomain has a certificate. Does mixed content hurt SEO? HTTPS is a Google ranking signal, and Google's page experience guidance expects pages served securely. More directly, a "Not secure" warning and broken scripts hurt trust and conversions. If you just moved to https, also check that canonical tags, the sitemap and internal links all use the https URLs. My redesign without losing SEO checklist covers the redirect side, and crawled, currently not indexed helps if pages drop out afterwards. Frequently asked questions Why does my website say "Not secure" when I have SSL? The page loads at least one resource over plain http, which is called mixed content, or the certificate has a problem such as being expired or issued for a different domain. The browser console lists any mixed content URLs. How do I find mixed content on my website? Open Chrome DevTools with F12, go to the Console and reload the page. Each insecure resource appears as a "Mixed Content" warning with its URL. Repeat on your main templates: home, blog post, product, contact and checkout. Is upgrade-insecure-requests enough to fix mixed content? It's a good safety net, but it only works if every resource is also available over https. Fix the URLs at the source first, then keep the header to catch anything you missed. How do I fix mixed content in WordPress? Set the WordPress and Site Address to https under Settings, then run wp search-replace from http to https with a backup and a dry run first. Fix any remaining hard-coded URLs in the theme or plugins and purge caches. Does mixed content affect Google rankings? HTTPS is a lightweight ranking signal, and mixed content weakens it. The bigger cost is lost trust: browser warnings and broken scripts make visitors leave and stop forms from converting. Want a secure, fast site that converts? I fix SSL, mixed content and redirect problems, and build business websites that are secure and SEO-ready from day one. See my technical SEO audit and fix , all website design services , or send me your URL .

Read article →
Emails Going to Spam? Fix SPF, DKIM and DMARC (2026)
Linux System AdminOct 9, 2026

Emails Going to Spam? Fix SPF, DKIM and DMARC (2026)

Business emails land in spam mostly because SPF, DKIM or DMARC is missing or broken. Gmail's "Show original" shows which one fails; fix it in your DNS. When your quotes, invoices or contact-form replies go to spam, you lose customers without ever knowing it. Gmail, Yahoo and Outlook now expect every sender to prove the email really comes from their domain, and they're strict about it. I set up DNS and mail for the sites I build and host, and when a client tells me "customers say they never got our email", the cause is nearly always one of the authentication records below, or a contact form that pretends to send from the visitor's address. Here's how to check yours in five minutes and fix it. Key takeaways Check a real message first. In Gmail, open it, click the three dots, then "Show original". It shows PASS or FAIL for SPF, DKIM and DMARC. SPF lists which servers may send for your domain. One record only, and every service you send through must be in it. DKIM signs each email. Turn it on in every service that sends for you: Google Workspace, Microsoft 365, your newsletter tool, your app's email provider. DMARC ties them together and tells inboxes what to do with failures. Start with p=none and reports, then tighten. Contact forms must send from your own domain and put the visitor's address in Reply-To , never in From . Why inboxes got strict Since February 2024, Gmail and Yahoo require every sender to have SPF or DKIM, and bulk senders (about 5,000 or more messages a day to Gmail) to have SPF, DKIM and DMARC, one-click unsubscribe for marketing mail, and a spam complaint rate under 0.3%. Microsoft started enforcing similar rules for Outlook.com, Hotmail and Live in May 2025. You may not be a bulk sender, but the same checks decide where your emails land. Unauthenticated mail is the first thing filters push to spam. Step 1: Read the verdict on a real email Send an email from your business address to a Gmail account. Open it, then three dots → Show original . At the top you'll see: SPF: PASS with IP 209.85.220.41 DKIM: 'PASS' with domain example.com DMARC: 'PASS' Any FAIL, NEUTRAL or a missing line tells you where to start. Do this for every way your business sends email: your mailbox, your website's contact form, your shop's order emails, your newsletter. They're often different systems, and each needs its own setup. From a terminal you can read the records directly: dig +short TXT example.com | grep spf dig +short TXT _dmarc.example.com dig +short TXT google._domainkey.example.com # selector depends on the provider Step 2: Fix SPF SPF is a TXT record on your domain listing who may send for it. A typical one for Google Workspace plus a transactional email service: example.com. TXT "v=spf1 include:_spf.google.com include:amazonses.com ~all" Mistakes I find all the time: Two SPF records. Adding a second v=spf1 record for a new service breaks both. Merge them into one record. A sending service not listed. Your CRM or invoicing tool sends as your domain but isn't in the record. Check each tool's docs for its include: value. More than 10 DNS lookups. Each include can trigger several lookups, and past 10 SPF fails with a "permerror". Remove services you no longer use. Ending with +all , which lets anyone send as you. Use ~all (soft fail) or -all . Step 3: Turn on DKIM everywhere DKIM adds a digital signature to every email. The sending service gives you a public key to publish in DNS, usually as a TXT or CNAME record under selector._domainkey.example.com , and then you switch signing on in its admin panel. In Google Workspace it's under Apps → Gmail → Authenticate email; Microsoft 365 has it in the Defender portal. Google Workspace doesn't sign with your domain until you publish the record and click "Start authentication". That last click is the step people miss. Do the same for each service that sends as your domain. DKIM is what survives forwarding, so it matters more than SPF in practice. Receiving inboxes check that the sending server is allowed (SPF), that the message is signed by your domain (DKIM), and that both match the From address (DMARC). Pass all three and the email has a fair chance at the inbox. Step 4: Add DMARC DMARC tells receivers what to do when SPF and DKIM don't line up with the domain in the From address, and sends you reports. Start in monitor mode: _dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com" After two to four weeks of reports showing all your legitimate senders pass, move to p=quarantine , then p=reject . That also stops scammers from sending fake invoices in your name. The raw reports are XML; a free DMARC report viewer makes them readable. Step 5: Fix your website's contact form This one is behind most "website emails go to spam" complaints I see. The form sends a message to you from the visitor's address, for example From: jane@gmail.com , through your web server. Gmail checks Gmail's DMARC policy, sees a server that isn't Gmail, and rejects or junks it. The correct setup: From: Website <forms@example.com> # your domain, authenticated To: sales@example.com Reply-To: jane@gmail.com # the visitor, so "Reply" still works And send through a proper email API or SMTP service (Amazon SES, Postmark, Resend, Brevo, or your Google Workspace account) with SPF and DKIM set up for example.com , not through PHP's mail() on the web server. Step 6: If you send from your own VPS Running your own mail server is possible, but it's the hardest path. Check these before blaming DNS: Port 25 is often blocked for outgoing mail by cloud providers by default. You may need to request unblocking. Reverse DNS (PTR) for the server's IP must point to a hostname that points back to the same IP. Set it in your VPS provider's panel. IP reputation. A new or previously abused IP starts with a bad reputation. Check it against blocklists, and use TLS for every connection. For most small businesses, a mailbox provider for people and a transactional email service for the website is cheaper than the hours a self-hosted mail server costs. My Cloudflare VPS setup checklist covers the DNS side of a new server, including not proxying mail records. Content and list habits still matter Only email people who asked , and make unsubscribing one click. Complaints hurt more than anything else. Separate marketing from transactional mail , for example newsletters from news.example.com , so a campaign can't drag down your invoices. Avoid link shorteners and image-only emails , and keep the visible link text matching the real URL. Watch Google Postmaster Tools once you send regularly. It shows your spam rate and domain reputation at Gmail. Frequently asked questions Why are my emails going to spam? The most common reasons are missing or broken SPF, DKIM or DMARC records, a contact form sending as the visitor's address, a poor sending IP, or recipients marking your mail as spam. Gmail's "Show original" tells you which authentication check fails. Do I need SPF, DKIM and DMARC? Yes, all three. Gmail and Yahoo require at least SPF or DKIM from every sender and all three from bulk senders, and Microsoft enforces similar rules. Without them your email is much more likely to be filtered or rejected. Can I have two SPF records? No. A domain with two v=spf1 records fails SPF entirely. Combine all senders into one record with several include: entries, and stay under 10 DNS lookups. How long do DNS changes for SPF, DKIM and DMARC take? Usually minutes to a few hours, depending on the record's TTL. Test with dig or an online checker, then send a fresh email and check "Show original" again. Old emails won't change. Why do my website contact form emails go to spam? Usually the form sends from the visitor's email address through your web server, which fails their domain's DMARC check. Send from your own domain through an authenticated email service and put the visitor's address in Reply-To. Want your emails in the inbox? I set up domains, DNS, SPF, DKIM, DMARC and website email sending for small businesses, and fix contact forms that lose leads. See my business website service , all Linux system admin services , or send me your domain and I'll check your records.

Read article →