when to use this runbook
A Flask blog can run for a long time with a local .env file, a small systemd service, and a working Nginx reverse proxy. The risk grows when the site becomes more automated. Publishing needs an API token. Search submission needs separate credentials. Flask sessions depend on SECRET_KEY. Gunicorn is controlled by systemd, while public traffic still depends on Nginx. If these values are edited in different places without a review path, a simple deployment can turn into a confusing incident: the server starts with an old database URL, the publish script has a stale token, or a new signing key invalidates every admin session at once.
This runbook is for a Stepnex-style independent blog that already has Gunicorn, systemd, and Nginx in place. It does not assume a large security team or a dedicated secret-management platform. The goal is to build a practical configuration baseline: keep secrets out of Git, load production values through a controlled systemd EnvironmentFile, validate required Flask settings during startup, rotate Flask signing keys with a temporary fallback, check Nginx before reload, and verify publishing with commands, URLs, and logs. For the deployment foundation, see the related Stepnex article Flask blog production deployment checklist.
Configuration ownership before editing files
Start by deciding which layer owns each configuration value. The repository should contain variable names, safe development defaults, and validation code. The production server should own runtime secrets such as SECRET_KEY, AUTO_PUBLISH_TOKEN, DATABASE_URL, and search submission tokens. The automation runner should own only the credentials required to call the publish API. Nginx should own proxy, TLS, request-size, cache, and static-file behavior, not application secrets.
This separation is simple, but it prevents many real failures. If an article publish fails, you can check the automation environment first. If the service cannot start, you can check the server environment file and systemd journal. If the public site returns 502, you can check Nginx and Gunicorn separately. A secret manager may be useful later, but a small blog should first make the boring path reliable.
Group the variables into four buckets. Security variables include SECRET_KEY, SECRET_KEY_FALLBACKS, AUTO_PUBLISH_TOKEN, CSRF settings, and admin-only tokens. Connection variables include DATABASE_URL, Redis endpoints, and SQLite paths. Submission variables include BAIDU_SITE, BAIDU_TOKEN, GOOGLE_SITEMAP_URL, and the public site URL. Operational switches include LOG_LEVEL, feature flags, and post-publish submission toggles. Each bucket needs a different review rhythm. Security values need rotation notes. Submission values need freshness checks. Operational values need rollback instructions.
steps: create a server-side environment file
Create a dedicated production environment file outside the repository. The exact path can vary, but it should be owned by root and readable only by the service group.
sudo install -o root -g www-data -m 0640 /dev/null /etc/stepnex-blog.env
sudo editor /etc/stepnex-blog.env
Keep the file as plain key-value assignments. Do not treat it as a shell script, and do not rely on command substitution inside it.
FLASK_ENV=production
SITE_PUBLIC_URL=https://stepnex.cn
DATABASE_URL=sqlite:////var/www/blog/database.db
SECRET_KEY=replace-with-new-random-token
SECRET_KEY_FALLBACKS=replace-with-previous-token
AUTO_PUBLISH_TOKEN=replace-with-api-token
BAIDU_SITE=stepnex.cn
BAIDU_TOKEN=replace-with-baidu-token
LOG_LEVEL=INFO
Generate new secret material with a command that produces high-entropy output:
python -c 'import secrets; print(secrets.token_hex(32))'
Then inspect ownership and permissions without copying values into shared logs:
sudo stat -c '%U %G %a %n' /etc/stepnex-blog.env
sudo grep -n 'SECRET\|TOKEN\|DATABASE' /etc/stepnex-blog.env
The second command is only for a trusted SSH session. Routine checks should print whether a variable exists, not the value itself. A common pitfall is making the file world-readable during an emergency and never tightening it again. Another pitfall is allowing a deployment script to recreate the environment file as empty because it assumes all configuration files live under the project directory.
steps: load configuration through systemd
Use a systemd override instead of editing the generated service file directly. This keeps local customizations visible and easier to review.
sudo systemctl edit stepnex-blog.service
Add the environment file and keep the execution command explicit:
[Service]
EnvironmentFile=/etc/stepnex-blog.env
WorkingDirectory=/var/www/blog
ExecStart=
ExecStart=/var/www/blog/.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app
Apply the change and inspect the service log:
sudo systemctl daemon-reload
sudo systemctl restart stepnex-blog
sudo systemctl status stepnex-blog --no-pager
journalctl -u stepnex-blog -n 80 --no-pager
Validate three things. First, daemon-reload actually ran, otherwise systemd may keep the old unit definition. Second, the journal has no missing configuration, database, import, or signing errors. Third, Gunicorn is still bound to the loopback address or an internal socket, so public traffic must pass through Nginx.
steps: validate Flask configuration at startup
Avoid scattering os.getenv calls across views, scripts, and extensions. Read production configuration in one place and fail fast when required values are missing.
import os
class ProductionConfig:
SECRET_KEY = os.environ.get('SECRET_KEY')
SECRET_KEY_FALLBACKS = [
item.strip() for item in os.environ.get('SECRET_KEY_FALLBACKS', '').split(',') if item.strip()
]
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
SITE_PUBLIC_URL = os.environ.get('SITE_PUBLIC_URL', 'https://stepnex.cn')
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
@classmethod
def validate(cls):
missing = [name for name in ['SECRET_KEY', 'SQLALCHEMY_DATABASE_URI'] if not getattr(cls, name)]
if missing:
raise RuntimeError('Missing production configuration: ' + ', '.join(missing))
Call this during application startup. A startup failure is noisy, but it is better than running a half-configured site. If your Flask version and extensions support fallback signing keys, rotate the main SECRET_KEY by moving the previous value into SECRET_KEY_FALLBACKS, restarting the service, and removing the old value after the planned session window. Do not replace the main key and remove the old one in the same operation unless you intentionally want to force every session to sign in again.
The same discipline applies to API tokens. If the publish API only accepts one token, coordinate the server change and the automation change in a short maintenance window. If it supports multiple active tokens, name each token and record last use so old credentials can be retired deliberately.
steps: check Nginx before reload
Flask configuration can be correct while the public site is still broken because Nginx points to an old upstream, rejects a body size Flask would accept, or serves static files from the wrong directory. Treat Nginx as part of the same deployment review.
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl status nginx --no-pager
Only reload after nginx -t succeeds. Then validate public behavior and logs:
curl -I https://stepnex.cn/
curl -I https://stepnex.cn/sitemap.xml
sudo tail -n 100 /var/log/nginx/access.log
sudo tail -n 100 /var/log/nginx/error.log
The pitfall is hiding Flask restart and Nginx reload inside one silent script. If the site returns 502, you need to know whether Gunicorn failed to start, the proxy target is wrong, or the reload never happened. A script is fine, but it should print each command, stop on failure, and show the last relevant log lines.
steps: verify publishing and search submission credentials
The production host and automation runner have different configuration surfaces. A server restart can succeed while the local publish command still lacks ONLINE_BLOG_PUBLISH_URL or AUTO_PUBLISH_TOKEN. Check presence without printing secrets:
$names='ONLINE_BLOG_PUBLISH_URL','AUTO_PUBLISH_TOKEN','BAIDU_SITE','BAIDU_TOKEN','GOOGLE_SITEMAP_URL'
foreach($n in $names){ if([Environment]::GetEnvironmentVariable($n)){ "$n=SET" } else { "$n=MISSING" } }
Before publishing production content, keep the article quality gate in the path:
python scripts/validate_article_quality.py article.zh.json article.en.json
python scripts/publish_article_api.py --input article.zh.json --submit-after-publish
python scripts/publish_article_api.py --input article.en.json --submit-after-publish
After publishing, validate the visible URL and sitemap entry:
curl -I https://stepnex.cn/article/your-slug-zh
curl -s https://stepnex.cn/sitemap.xml | grep your-slug-zh
journalctl -u stepnex-blog -n 120 --no-pager
If Baidu or Google credentials are missing, publishing can still work, but post-publish submission is incomplete. Report missing credentials explicitly so the next automation run does not rediscover the same gap.
validate with a small rotation drill
A configuration runbook only becomes trustworthy after a drill. Choose a quiet window and rotate one secret. For a Flask session key, generate a new value, place the previous value in SECRET_KEY_FALLBACKS, restart the service, browse the admin area, run a small publish test, and inspect logs.
Use this checklist:
systemctl status stepnex-blogreports a stable service.journalctl -u stepnex-bloghas no missing configuration, database, or signing errors.- Admin sessions do not fail unexpectedly; if they do, check fallback-key support.
curl -Ifor the home page, an article page, and the sitemap returns expected status codes.- The publish script returns a URL, and that URL is reachable.
- Nginx
access.logdoes not show a sudden rise in 502, 499, or request-body failures.
This does not promise perfect safety. It gives you evidence that the main paths still work after a controlled configuration change.
Pitfall checklist and review routine
Do not commit .env files. A private repository is still a broader exposure surface than a locked server file. Do not print full environment values in application logs. Do not rotate all secrets in one step unless there is an active incident. Flask session keys, CSRF secrets, publish tokens, and search submission credentials have different blast radiuses. Do not forget Nginx when diagnosing application changes. A correct Flask app behind a stale proxy target still behaves like a broken deployment.
After every production configuration change, write a short review note: time, operator, variable names changed, whether a secret was involved, whether daemon-reload ran, whether Flask restarted, whether Nginx reloaded, which URLs were checked, what the logs showed, and how to roll back. The note should never contain the secret value itself.
For monthly maintenance, review environment file permissions, list old fallback keys, confirm that stale tokens were removed, run nginx -t, inspect the most recent service restart, and verify that publishing plus sitemap submission still works. This is enough for a small independent blog to avoid most configuration surprises. As the site grows, you can move high-value secrets into a dedicated secret manager, but the habits stay the same: explicit ownership, repeatable commands, readable logs, careful validation, and a short review after every change.