About this site
This site is a portfolio of my work, but it is also a working example of how I approach web development.
- Amazon SES
- AWS S3
- Bootstrap
- CloudFront
- Django
- Heroku
- PostgreSQL
- Wagtail
This site is built and operated using many of the same practices I use for clients' applications: structured content management, accessible frontend development, automated testing, documented deployment processes, managed infrastructure, security and privacy safeguards, monitoring, backups, and ongoing maintenance.
The site is intentionally modest in scale. It does not need every service or architectural pattern that might be appropriate for a larger application, and I have deliberately avoided adding complexity simply to demonstrate that I can. Instead, it provides an opportunity to apply the same principles I use on larger projects while making the technical decisions visible.
The sections below explain how the site is built, why particular approaches were chosen, how changes reach production, and how the application is maintained over time. Together, they provide a practical example of not just the software I build, but how I think about building and owning software.
This site is built as a Django application with Wagtail providing the content management layer. That distinction is intentional: Django provides the underlying application framework, data models, routing, forms, search, and other application behavior, while Wagtail provides the tools used to manage the site's editorial content.
PostgreSQL stores application and content data. Uploaded media and static assets are stored separately in Amazon S3 and delivered through CloudFront, keeping file storage independent of the application instances running the website.
The application is deployed on Heroku, with configuration and credentials supplied through environment variables rather than stored in the codebase. This allows the same application code to run in temporary Review Apps, staging, and production while each environment maintains its own configuration and services.
The architecture is intentionally modest. This is a relatively small website and does not need a large collection of infrastructure simply because those tools are available. At the same time, it uses many of the same patterns I use for larger production applications, including managed services, environment-specific configuration, external file storage, automated deployments, and a relational database.
Django is the foundation of the application. It handles the underlying request and application architecture and provides the framework for functionality that does not naturally belong in a content management system.
Wagtail runs on top of Django and provides the editorial layer. Pages are built from structured content components rather than unrestricted page layouts, giving the CMS enough flexibility to create different kinds of content while keeping presentation, accessibility, and responsive behavior within the application's templates.
This separation also means not every URL needs to be a CMS page. Site-wide functionality such as search can be implemented as a normal Django view, while portfolio pages and other editorial content remain managed through Wagtail.
The production application runs on Heroku with PostgreSQL as its relational database.
Static assets and uploaded media are stored in a private Amazon S3 bucket and delivered through CloudFront. The S3 bucket is not publicly accessible; CloudFront accesses the files through an Origin Access Control while the application uses dedicated AWS credentials when it needs to store files.
Email generated by the site, including messages from the contact form, is delivered through Amazon SES. The public contact address is handled separately from the application, allowing messages generated by the website to use the site's domain while replies are directed to the visitor who submitted the form.
Configuration specific to an environment - including database connections, AWS credentials, and email configuration - is kept outside the repository and supplied to the application at runtime.
This site could have been built with substantially less infrastructure. It could also have been built with substantially more.
The choices here are intended to provide a practical middle ground: enough structure to make the application secure, maintainable, testable, and representative of production work, without introducing services or abstractions that a site of this size does not need.
It also provides a place to use and demonstrate the same patterns that support larger applications: separating content management from application logic, keeping persistent files outside application instances, using managed infrastructure where appropriate, maintaining environment-specific configuration, and designing components for reuse.
Requirements change. An application that begins as a relatively simple website may later need authentication, background processing, external integrations, more sophisticated search, or functionality that was never anticipated when it was first built.
I try to make architectural decisions that meet current requirements without unnecessarily limiting future options. That does not mean building every capability in advance. Doing so would add complexity and cost for problems that may never exist. Instead, the goal is to choose foundations that allow new capabilities to be introduced when there is a demonstrated need for them.
For this site, that means starting with Django, Wagtail, PostgreSQL, and a small collection of managed services. There is currently no need for dedicated search infrastructure, background workers, or a distributed cache, for example. Those services can be added later without fundamentally changing the application if its requirements eventually justify them.
The same principle applies when an existing decision stops being the right one. Previous investment is useful context, but it should not become the sole reason to continue with an approach that no longer serves the application. Sometimes extending an existing system is the sensible choice; sometimes replacing or refactoring part of it creates a better long-term foundation.
Good architecture therefore needs to support change in both directions: making it possible to add capabilities without having anticipated every future requirement, while also making it possible to replace earlier decisions without treating them as permanent simply because time has already been invested in them.
Content management and accessibility are closely related. A website can be accessible when it launches and gradually become less so if routine content changes allow editors to introduce inconsistent headings, inaccessible controls, poorly structured markup, or other problems.
For that reason, this site treats accessibility as both a frontend development concern and a content-management concern. Templates and reusable components handle as much of the underlying structure and presentation as possible, while the CMS gives editors control over the content itself.
WCAG 2.2 AA is used as the general accessibility target, with accessibility considered during component development, content entry, testing, and ongoing maintenance.
The site uses reusable Wagtail StreamField blocks for common content patterns such as headings, rich text, cards, images, screenshots, notes, and architecture diagrams.
Rather than allowing editors to control arbitrary markup or presentation, each component represents a particular kind of content and determines how that content should be rendered. This keeps the editing interface relatively simple while providing consistent semantic HTML, responsive behavior, and accessibility.
Portfolio projects use structured fields for information that has a defined meaning - such as summaries, technologies, project links, and featured images - while StreamField is used for the narrative portion of each case study.
This approach deliberately places some constraints on content editing. For example, page titles provide the primary heading, heading components limit the available heading levels, image components manage alternative text, and specialized components handle more complex interfaces. Editors control the information without having to recreate the markup that makes it work.
Accessibility is built into the site's shared templates and components so that individual pages do not need to solve the same problems repeatedly.
The site uses semantic HTML, logical heading structures, keyboard-accessible navigation and controls, visible focus states, descriptive links, and responsive layouts. A skip link allows keyboard users to bypass repeated navigation, and interface components are designed to preserve appropriate labels and states for assistive technologies.
Color and presentation are also designed around reusable theme variables rather than assumptions about a particular background or color scheme. This allows components to work consistently in both light and dark modes while maintaining the semantic structure underneath.
Images are managed through Wagtail's image system and rendered at sizes appropriate to their context rather than simply serving the original upload.
Content images, screenshots, logos, and other visual elements have different purposes and are handled accordingly. Alternative text is associated with meaningful images, while duplicated or decorative imagery can be hidden from assistive technologies when it does not provide additional information.
Architecture diagrams are also designed with accessibility in mind. The visual diagram is supplemented by structured text describing the same relationships, so understanding the content does not depend solely on interpreting an SVG.
Accessibility does not end when a component is initially developed.
Wagtail's built-in accessibility checks provide feedback during content editing, while automated site scans can identify issues across published pages. Automated tests can protect application behavior and markup where appropriate, and manual keyboard and screen-level review remains useful for interactions that automated tools cannot fully evaluate.
These checks are particularly important for a content-managed website because the site continues to change after development. The objective is to make accessible content easier to produce and to provide multiple opportunities to identify problems when they occur.
A content management system does not necessarily become better by giving editors more control over presentation. In many cases, limiting those choices produces a better experience for both editors and visitors.
Content managers should be able to focus on what they know best: creating accurate, useful content, organizing information, and keeping the website current. They shouldn't need to understand HTML, make design decisions for every page, or take responsibility for the technical details of accessibility and responsive layouts.
Structured content components establish a clear division of responsibility. The CMS allows editors to define what a piece of content means and how it relates to other information on the site. The application determines how that content should be presented accessibly and consistently across different pages, devices, and screen sizes.
These constraints aren't intended to limit what content managers can accomplish. They're intended to remove unnecessary decisions, reduce opportunities for error, and make routine maintenance easier. By building accessibility and presentation into reusable components, improvements can also be made in one place and applied throughout the website without requiring editors to revisit individual pages.
The result is a CMS that gives content managers meaningful control over their work while allowing developers to maintain the consistency, accessibility, and technical quality of the website.
Building an application is only part of putting reliable software into production. My projects use a consistent development and deployment workflow designed to make changes reviewable, testable, documented, and reversible before and after they reach production.
Source code is maintained in Git and GitHub, with changes developed locally in branches and reviewed through pull requests. Automated tests exercise application behavior, data processing, permissions, and integrations, with factories used to create predictable test data without relying on copies of production databases. External services can be mocked or isolated so failure scenarios can be tested without making real requests to production systems.
Applications are deployed through Heroku Pipelines with persistent staging and production environments. Pull requests can create temporary Review Apps, providing isolated environments where changes can be tested and reviewed before they are merged. After merging, changes are deployed to staging for integration testing and user acceptance testing before being promoted to production.
Configuration and credentials are kept outside the codebase and managed separately for each environment. This allows the same application code to move through Review Apps, staging, and production without embedding environment-specific settings or sensitive information in the repository.
Together, source control, automated testing, isolated review environments, staging, and controlled production deployments provide multiple opportunities to identify problems before they affect users. Just as importantly, they provide a predictable process for continuing to change applications after their initial launch.
Documentation is treated as part of the application rather than something written only when a project is handed off.
For the applications I maintain, I use Sphinx to build structured documentation alongside the software. Documentation typically covers application architecture, local development and deployment, configuration, data models, integrations, scheduled processes, administrative tools, and troubleshooting procedures. More complex systems also document how information moves between services and which system is responsible for maintaining particular data.
Where appropriate, technical documentation is supplemented by separate guidance for content managers and other application users. This keeps developer-focused implementation details from overwhelming people who primarily need to understand how to manage content or perform routine administrative tasks.
Keeping this information documented is particularly important for long-lived applications. Code can explain what a system does, but it rarely captures why an architectural decision was made, how an external integration is configured, what operational dependencies exist, or what someone should investigate when a process fails.
Documentation is maintained as the application evolves so that it remains a useful part of operating and developing the system rather than becoming a snapshot of how the application worked when it was first launched.
Security and privacy are treated as ongoing application requirements rather than features added immediately before launch. The site uses framework-level protections, managed infrastructure, restricted credentials, and application-specific safeguards to reduce unnecessary exposure while keeping the architecture relatively simple.
The same principle applies to visitor privacy. Third-party services are evaluated not only for what they provide, but also for what information they receive and when they are allowed to receive it.
Django provides many of the application's baseline security protections, including defenses against common web vulnerabilities such as cross-site request forgery, cross-site scripting, and unsafe database queries. Application code builds on those protections rather than replacing them with custom implementations.
Administrative functionality is provided through Wagtail and Django's authenticated interfaces rather than exposed through custom public management tools. Permissions can be assigned according to the level of access individual users require.
The application also uses middleware to identify and reject common automated requests for sensitive files, configuration data, development tools, and other resources that should never be publicly accessible. These requests are blocked before reaching normal application processing and are logged where appropriate for review. This protection is provided by django-requestguard, a reusable Django package I originally developed to centralize these rules across multiple applications.
Credentials and environment-specific configuration are kept outside the source code repository and supplied to the application at runtime.
Access to external services follows the principle of limiting credentials to the resources and operations they actually require. For example, the application's AWS credentials are scoped to the S3 resources it needs to manage rather than providing unrestricted access to the AWS account.
Public access to stored files is also separated from application access. The site's S3 bucket is private, with CloudFront authorized to retrieve files through an Origin Access Control. Visitors receive files through the CDN rather than receiving direct public access to the storage bucket.
Separate credentials are used for unrelated services where appropriate, reducing the impact if an individual credential ever needs to be rotated or revoked.
Third-party services can introduce privacy considerations even when they are not visibly interactive parts of a website.
Where services such as analytics, embedded media, maps, or other external tools are used, consent management can prevent optional services from loading until the visitor has made the appropriate privacy choice. This keeps consent behavior tied to the technology actually being used rather than treating the cookie banner as a purely visual requirement.
The goal is to minimize unnecessary data collection and avoid loading third-party services simply because they are convenient to add.
For this portfolio specifically, we should eventually adjust that paragraph to describe exactly what we deploy here. If we don't end up using analytics or Usercentrics, we shouldn't describe controls that aren't actually present. But it's a good place to explain the broader approach if we implement them.
Public forms are designed to collect only the information required for their purpose.
The contact form uses server-side validation and abuse controls without requiring visitors to create an account. Basic anti-automation measures and rate limiting reduce unwanted submissions while avoiding unnecessary third-party CAPTCHA services unless abuse makes stronger protection necessary.
Messages are delivered through Amazon SES rather than stored indefinitely as application data. Credentials used for email delivery are separate from those used for file storage, and replies are directed to the address supplied by the visitor without exposing those details in the public interface.
Security also depends on keeping the software underneath an application current.
Frameworks, libraries, and infrastructure dependencies are reviewed and updated as part of ongoing maintenance. Changes can be tested through the same pull request, automated testing, Review App, and staging workflow used for other application development before reaching production.
Logging provides visibility into unexpected behavior and application failures, while database backups and deployment safeguards support recovery when something does go wrong.
Security controls are therefore treated as part of the application's lifecycle rather than as a one-time launch requirement.
Security decisions should reflect the risks and responsibilities of the application. A small public website does not require every security service available from a cloud provider, but it still benefits from private storage, restricted credentials, secure framework defaults, dependency maintenance, backups, logging, and protection against common automated attacks.
The objective is not to accumulate security products. It is to reduce unnecessary exposure, limit the impact of failures, and make security part of the normal development and maintenance process.
Deploying an application is the beginning of its operational life, not the end of the development process.
Production applications require ongoing attention to backups, logging, scheduled processes, dependencies, external services, and the infrastructure they rely on. I try to make those responsibilities part of the application architecture rather than relying on someone to remember a collection of manual maintenance tasks.
Where practical, routine checks and maintenance are automated so that problems can be identified before they are reported by users.
Applications use centralized logging to provide visibility into errors, failed integrations, unexpected requests, and other operational issues.
Application code logs enough context to make failures diagnosable without unnecessarily recording sensitive information. Integration workflows and scheduled processes report failures separately from normal application activity so that problems involving external systems can be identified and investigated.
Not every logged event requires immediate attention. For conditions that may affect application availability, data integrity, or an important business process, applications can generate email notifications rather than depending on someone periodically reviewing logs. Examples include database connectivity problems, failed API or webhook processing, unsuccessful scheduled tasks, and unusual patterns of application errors or invalid requests.
This creates a distinction between information that should be available for investigation and conditions that should actively get someone's attention. The objective is to make significant failures visible quickly without turning routine application activity into unnecessary alerts.
Production databases are backed up regularly, with automated backups retained independently of normal application operation.
Backups are particularly important for content-managed applications because the database contains not only application data but also editorial work that may have accumulated over many years.
The objective is not simply to have a backup process configured, but to make recovery part of the operational design: persistent data is clearly identified, files are stored independently of disposable application instances, and infrastructure can be recreated without relying on the state of an individual server.
Tasks that need to happen predictably are automated wherever practical.
Depending on the application, scheduled processes may synchronize data with external systems, create database backups, check for broken links, perform accessibility scans, refresh cached information, or carry out other routine maintenance.
Background processing is used when work does not need to occur during a visitor's request. Applications with more substantial asynchronous workloads can use Celery and Redis to queue and process that work independently of normal web traffic.
This reduces reliance on manual processes while keeping potentially slow or unreliable work away from visitor-facing requests.
A website can continue running while parts of it quietly deteriorate.
Links can stop working, third-party services can change, content can introduce accessibility problems, integrations can fail, and dependencies can become outdated. Automated checks help identify some of these issues before they become larger maintenance problems.
For content-managed sites, I use automated broken-link auditing and accessibility scanning alongside application testing and logging. These checks complement rather than replace manual review, but they provide useful ongoing visibility into a site that continues to change after launch.
Applications depend on frameworks, libraries, databases, cloud services, and external APIs that continue to evolve after the application is deployed.
Dependencies are reviewed and updated periodically rather than being left indefinitely at the versions used during initial development. Framework and database upgrades are tested through the normal development and deployment workflow before reaching production.
External integrations require similar attention. APIs, authentication requirements, data structures, and third-party platforms change over time, so maintaining an integration includes responding to changes outside the application's own codebase.
I generally approach applications with the expectation that they will need to be understood, maintained, and extended long after their initial launch - often by developers, content managers, or administrators who were not involved in the original design and development.
That affects decisions made throughout the project: how code is structured, how systems are documented, how administrative interfaces are designed, how deployments are performed, how failures are reported, and how routine maintenance is automated. Wherever practical, the application should explain itself through predictable conventions, clear documentation, and interfaces that do not depend on knowledge held only by the people who originally built it.
This also favors maintainable, well-understood approaches over unnecessary complexity. A clever solution has limited value if the next person responsible for the application cannot reasonably understand, troubleshoot, or extend it.
Robustness does not mean that failures never occur. Databases, networks, APIs, credentials, dependencies, and external services all eventually create problems. The goal is to design applications so that failures are visible, recoverable, and understandable - even when the person responding to the problem is encountering the application for the first time.
Cost is an architectural consideration.
Cloud infrastructure makes it relatively easy to add databases, caches, background workers, monitoring platforms, CDNs, storage services, and other components. Each may be useful, but each also introduces financial and operational cost. I try to add infrastructure when it solves a real problem rather than simply because it is commonly associated with a particular technology stack.
This site is deliberately small, but it still uses production-grade services where they provide meaningful benefits. The Django application and PostgreSQL database run on Heroku, files are stored in Amazon S3 and delivered through CloudFront, and transactional email is sent through Amazon SES.
For a site with relatively modest traffic, services such as S3, CloudFront, and SES are inexpensive at this scale. The primary recurring expense is the application and database hosting.
The site could support additional infrastructure. Celery and Redis could provide background processing, a dedicated search service could replace the built-in search implementation, and additional monitoring or security services could be introduced.
At its current scale, however, those services would provide little practical benefit.
The application therefore starts with the simplest architecture that meets its requirements while leaving room to introduce additional services if those requirements change. This keeps both operating cost and system complexity proportional to the application.
Infrastructure pricing is only one part of the cost of operating software. Every additional service also needs to be configured, secured, monitored, documented, updated, and understood by whoever maintains the application.
A service that costs only a few dollars per month can still be expensive if it introduces unnecessary complexity or creates another point of failure. Conversely, paying for a managed service can be worthwhile when it eliminates significant maintenance or provides capabilities that would be costly to reproduce reliably.
I consider both sides when making infrastructure decisions: the direct cost of the service and the ongoing cost of owning it.
At the time of writing, this site costs approximately $25 per month to operate.
Most of that cost is the Heroku application and PostgreSQL database. S3 storage, CloudFront delivery, and SES email add relatively little at the site's current traffic level. The domain name is an additional annual expense.
These figures will change as provider pricing and site usage change, but they provide a useful indication of what this architecture costs in practice.
There is no single architecture that is appropriate for every application. A system should be complex enough to meet its requirements for reliability, security, maintainability, and performance—but no more complex than it needs to be.
For this site, that means a relatively small collection of managed services, conventional application architecture, automated safeguards, and infrastructure that can grow if the requirements change.
The objective is not to demonstrate how many technologies can be assembled into one website. It is to demonstrate how technical decisions can be made deliberately.