One lesson I keep encountering while building and deploying Django applications is simple:
Working locally does not guarantee working in production.
I experienced this again while deploying the contact form for my developer portfolio.
The contact form itself was straightforward. A visitor could submit their details through my website, Django would process the request, and the application could use email to notify me about the new inquiry.
During local development, the email functionality worked.
After deployment, it didn't.
Instead of receiving the expected email, the production application encountered an SMTP connection error.
This became another useful lesson in separating application logic from the external services an application depends on.
Building the Contact Workflow
My portfolio includes a dedicated contact page so potential clients, recruiters, or other visitors can contact me directly.
From the frontend, it looks like a normal form.
But on the backend, several things have to happen correctly:
Visitor
↓
Contact Form
↓
Django View
↓
Validate / Process Submission
↓
Store or Handle Data
↓
Email Service
↓
Notification
The important realization is that these aren't all the same system.
Django can successfully receive and process a form while an external email connection still fails.
That distinction became important once I deployed the project.
Configuring Email in Django
For the email integration, I configured Django to use its SMTP email backend.
The relevant configuration followed this structure:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
Credentials were loaded from environment variables rather than being directly written into the application logic:
EMAIL_HOST_USER = config("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD")
Keeping credentials outside the source code is especially important once an application is deployed.
The application can read the necessary configuration from its environment without requiring sensitive values to be hardcoded throughout the project.
Everything Worked Locally
While testing the contact functionality locally, the SMTP setup worked.
That gave me confidence that the basic Django email configuration and form workflow were functional.
Then I deployed the application.
The production environment exposed a problem I hadn't seen locally.
The application couldn't establish the SMTP connection as expected.
This is an important distinction when debugging:
Local environment
Django → SMTP server → Email
✓
Production environment
Django → SMTP connection
✕
The application code had moved to a different environment, and that environment didn't necessarily behave like my development machine.
Investigating the Production Failure
At this point, it would have been easy to assume:
"The contact form is broken."
But that description would have been too broad.
The form and SMTP delivery are separate parts of the workflow.
So the useful debugging question became:
Which part is actually failing?
I had to distinguish between several possibilities:
- Was Django receiving the POST request?
- Was the form data valid?
- Was the backend logic executing?
- Were the email settings available in production?
- Or was the failure happening when Django attempted to communicate with the SMTP server?
The production error pointed toward the SMTP connection.
That narrowed the problem considerably.
Instead of rewriting the entire contact feature, I could focus on the external email-delivery stage.
Why External Services Change the Failure Model
A web application rarely operates completely on its own.
Production applications may depend on:
Application
├── Database
├── Email provider
├── File storage
├── APIs
├── DNS
└── Hosting infrastructure
Every external dependency introduces another possible failure point.
That means successful application logic doesn't necessarily mean the entire workflow will succeed.
For my contact form, Django could do its job correctly while email delivery could still fail independently.
That changed how I thought about the feature.
Making the Contact Workflow Less Dependent on Email
An email notification is useful.
But the actual contact submission is more important.
If a potential client sends a message through my portfolio and SMTP happens to fail, I don't want the entire inquiry to disappear just because the notification layer failed.
So I moved toward a safer approach:
preserve the submission first rather than making successful SMTP delivery the only path to receiving the message.
Conceptually, the workflow becomes:
Visitor submits message
↓
Django validates request
↓
Save contact submission
↓
Attempt notification
↓
Email succeeds OR fails
↓
Submission still exists
This is a much more resilient design than treating email as the database.
Why Saving the Submission Matters
Imagine this workflow:
send_email(contact_data)
return success_response()
If send_email() fails before anything else records the inquiry, the message can effectively be lost.
A more resilient architecture separates persistence from notification:
contact = Contact.objects.create(
name=name,
email=email,
message=message,
)
# Notification can be handled separately.
The database now becomes the source of truth for the inquiry.
Email becomes a notification mechanism.
That is a small architectural distinction, but an important one.
What I Did Not Do
There is another important part of documenting real development work:
Don't pretend an unresolved problem was solved.
I didn't want to publish an article claiming that I discovered a magical SMTP configuration that fixed everything in production when that wasn't what happened.
The SMTP connection issue itself remained something I could revisit with a production-focused email solution.
Instead, I changed the way I thought about the contact feature.
The important business requirement wasn't:
Send me an email.
It was:
Don't lose a visitor's inquiry.
Those aren't necessarily the same requirement.
The Result
The experience pushed me toward a more reliable contact workflow where submitted information could be preserved independently of whether an email notification succeeded.
It also gave me another reminder that deployment is not simply the final step after development.
Production changes the environment in which your assumptions are tested.
A feature can contain perfectly valid Django code and still fail because it depends on something outside Django.
What I Learned
The biggest lesson was to separate core application functionality from notifications and external integrations.
For a contact system:
Receiving the inquiry is core functionality.
Sending an email about the inquiry is a notification.
The notification should not necessarily determine whether the underlying business operation succeeds.
I also learned to narrow production problems before trying to fix them.
"Contact form doesn't work" is not a useful diagnosis.
"Form processing succeeds, but the application encounters an SMTP connection failure during email delivery" is much more actionable.
That level of isolation makes debugging considerably easier.
Building Beyond runserver
Working on my Django portfolio has repeatedly shown me that production development involves much more than writing views and templates.
It means thinking about:
- external dependencies,
- environment configuration,
- failure handling,
- data persistence,
- deployment infrastructure,
- and what should happen when one part of a workflow becomes unavailable.
The contact form looked like a small portfolio feature.
But deploying it turned it into a useful backend engineering lesson:
Design around the operation that must succeed, and treat external notifications as dependencies that can fail.
That's a principle I plan to carry into larger Django applications as well.
I'm documenting the real technical problems, decisions, and lessons I encounter while building production Django applications.
You can explore my portfolio at saddamshah.com.