Build Bubble
KINDAreplaces $59/mosaves $708/yrback to the verdict
One real web application built on Django, with the pieces Bubble hands you already present: user accounts and password reset, a data layer with migrations, an admin interface for your tables, form driven workflows, background jobs, Stripe payments and a deploy with backups. The worked example is a small booking marketplace; substitute your own domain in Phase 2.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs Django. Older versions will work and will also expire sooner.
Get it Install from python.org or with your package manager, then create a virtual environment in the project directory. open ↗
Verify
python3 --version prints 3.12 or higher - installfree
Why The production database. Developing on SQLite and deploying on PostgreSQL hides differences until the worst moment.
Get it brew install postgresql@16, or your package manager, or run it in Docker. Create a database and a user for the project. open ↗
Verify
psql --version prints 16 or higher and you can connect to your project database - API keyfree in test mode; live payments cost per transaction, currently around 2.9 percent plus a fixed fee in the United States
Why Phase 4 needs a publishable key, a secret key and a webhook signing secret. Test mode does everything except move real money.
Get it Sign up at stripe.com, then Developers > API keys for the two keys. The webhook signing secret comes from the endpoint you register, or from the stripe listen command during development. open ↗
Verify
stripe listen prints a webhook signing secret beginning whsec_ - API keymost providers have a free tier of a few thousand messages a month; verify current limits when you sign up
Why Password reset and confirmations both need real delivery. The console backend makes broken email look fine.
Get it Create an account with a transactional provider, verify a sending domain by adding the DNS records they give you, and take an API key or SMTP credentials. Domain verification is the slow part, so start it early.
Verify
A password reset email arrives at an inbox you own - have readyroughly 10 to 15 USD a year for a domain and 5 to 10 USD a month for a small server
Why Product phase only. Needed for certificates and for anything anyone else will use.
Get it Any registrar for the domain, any small virtual server provider for the host. One or two gigabytes of memory is enough for this.
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- The worked example is a booking marketplace. Replace these tables with your own domain in Phase 2 and keep the shape. - User · Django's built-in model, extended with a profile rather than replaced - listings(id, owner_id, title, description, price_cents, active, created_at) - availability(id, listing_id, starts_at, ends_at, booked) - bookings(id, listing_id, guest_id, starts_at, ends_at, state, total_cents, stripe_session_id) - messages(id, booking_id, sender_id, body, sent_at) - Every state-changing action is a form POST to a view. That is what a Bubble workflow compiles down to in a code project.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DJANGO_SECRET_KEYsecret | required | replace-with-50-random-characters | Generate once with get_random_secret_key(). Changing it invalidates every session and signed value. |
DJANGO_DEBUG | required | 0 | Zero everywhere except your own machine. Debug mode leaks settings and stack traces to visitors. |
DATABASE_URLsecret | required | postgres://user:pass@localhost:5432/appdb | Your PostgreSQL connection string. |
ALLOWED_HOSTS | required | example.com,www.example.com | The hostnames this application answers to. Django refuses anything else, which is the point. |
STRIPE_SECRET_KEYsecret | optional | sk_test_... | Stripe dashboard, Developers > API keys. Use the test key until the restore drill has been done. |
STRIPE_WEBHOOK_SECRETsecret | optional | whsec_... | Printed by stripe listen during development, or shown when you register the endpoint in the dashboard. |
EMAIL_URLsecret | required | smtp://user:pass@smtp.provider.com:587 | Your transactional email provider's SMTP credentials. |
The build, in order
Project, accounts and the admin
Signup, login, logout and password reset all work, and you can edit data through the admin, before you write a single feature.
Start the project, add psycopg, and set the database from environment variables. Never commit a settings file with credentials in it.
Files
config/settings.py.env.exampleterminalpython -m venv .venv pip install django psycopg[binary] python-dotenv django-admin startproject config . python manage.py migrate
Include django.contrib.auth.urls, add templates for login and the reset flow, and configure SMTP against a transactional provider. Send yourself a reset to prove it.
Files
templates/registration/terminalpython manage.py createsuperuser
Move it off /admin, require staff status, and confirm an anonymous request gets a login page. Turn DEBUG off outside local development and set ALLOWED_HOSTS.
Files
config/urls.py
done when · tick each as it passeswatch out- Django's password reset does nothing visible without a working email backend, and the development console backend makes it look like it worked. Point it at a real transactional provider now, or you will discover this the week you launch.
Your data model and migrations
Your real tables exist, with constraints the database enforces rather than the form.
Define the models and add Meta.constraints for the invariants. A CheckConstraint for a positive price and an ordered date range costs three lines and holds forever.
Files
app/models.pyterminalpython manage.py makemigrations python manage.py migrate
list_display, search_fields and list_filter for each model. This is the closest analogue to Bubble's data tab and it is worth ten minutes.
Files
app/admin.pyA management command that creates a few users, listings and bookings so every later screen has something to render and every query has rows to be slow against.
Files
app/management/commands/seed.pyterminalpython manage.py seed
done when · tick each as it passeswatch out- Validating only in the form leaves the database willing to hold nonsense the moment anything else writes to it, including a management command or the admin. Put the rule in a constraint and let the form report the violation.
Screens and workflows
A visitor can complete the main journey end to end without you touching the admin.
List, detail and create. Use ModelForm so validation lives with the model. Keep templates thin.
Files
app/views.pytemplates/Load HTMX from a vendored file rather than a content delivery network, and return HTML fragments from views. No build step, no bundler.
Files
static/htmx.min.jsA test that logs in as user B and posts to user A's object, expecting a refusal. This is the test that catches the mistake everyone makes.
Files
app/tests/test_permissions.pyterminalpython manage.py test
done when · tick each as it passeswatch out- Hiding a button in a template is not authorisation. Anyone can post to the URL. Check ownership and permission inside the view, and write a test that posts to another user's object and expects a refusal.
Payments
Money moves, and your database agrees with Stripe about what happened.
Never trust a price from the client. Look the amount up from your own database and pass it to Stripe.
Files
app/payments.pyterminalpip install stripe
Verify the signature with the endpoint signing secret, then update state. Exempt this view from CSRF, because it is a server to server call, and rely on the signature instead.
Files
app/webhooks.pyStore the processed event id and ignore duplicates. Use the Stripe command line tool to replay an event and assert nothing changes the second time.
terminalstripe listen --forward-to localhost:8000/webhooks/stripe/ stripe trigger checkout.session.completed
done when · tick each as it passeswatch out- Marking the order paid on the success redirect is the classic and expensive mistake. The visitor can close the tab, and the redirect can be replayed. The webhook is the source of truth and the redirect is only a nice screen.
Background work and email
The things that should not block a request stop blocking it, and failures are visible.
Rows with state, attempts and last error. Views write rows; the command sends them. This is what makes retries and visibility possible.
Files
app/models.pySelect pending rows, send, mark sent or failed with the error text. Cap attempts and back off.
Files
app/management/commands/send_outbox.pyterminalpython manage.py send_outbox
A cron entry every minute. A test that runs the command twice against one pending row and asserts one send.
done when · tick each as it passeswatch out- Reaching for Celery and Redis on day one adds two services to operate for work that a cron entry handles at this size. Add the queue when a command is too slow or needs retries within seconds, and not before.
Deploy, backups and a restore you have doneproduct builder
It runs somewhere other than your laptop, and you have proved you can bring it back.
Gunicorn behind Caddy, static files collected and served by Caddy, secrets from the environment, DEBUG off and ALLOWED_HOSTS set.
Files
Caddyfileterminalpython manage.py collectstatic
pg_dump on a schedule, encrypted or to a destination only you control, keeping several generations.
terminalpg_dump "$DATABASE_URL" | gzip > backup-$(date +%F).sql.gz
Restore the newest dump onto a second machine, run migrations, and compare row counts. Write the date in the README.
Files
README.mdterminalgunzip -c backup.sql.gz | psql "$RESTORE_URL"
done when · tick each as it passeswatch out- A backup on the same server is not a backup; it dies with the disk it lives on. Get the dump off the machine, and confirm you can read it from wherever it landed.
That is the whole plan for Bubble. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- The visual editor, which is the product Bubble actually sells.
- The plugin marketplace and the integrations that come with it.
- Managed hosting, scaling and the operational cover that goes with a platform.
- The ability for a non-developer on your team to change the application without you.
- visual editor
- workflow builder
- plugin marketplace
- hosting/scaling
- nondeveloper maintenance
- templates
- A JSON API for the parts a mobile client would need
- Full text search using PostgreSQL rather than adding a search service
- A staging environment restored from last night's production dump
Need the files? The project pack on the verdict page hands your agent the whole brief · more no-code apps.